diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt index f6fa5007..d7a0df92 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt @@ -1,9 +1,12 @@ package kr.co.vividnext.sodalive.v2.main import android.Manifest +import android.annotation.SuppressLint +import android.content.BroadcastReceiver import android.content.Context import android.content.ComponentName import android.content.Intent +import android.content.IntentFilter import android.os.Build import android.os.Bundle import android.os.Handler @@ -39,6 +42,7 @@ import kr.co.vividnext.sodalive.audition.AuditionActivity import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.common.Constants import kr.co.vividnext.sodalive.common.SharedPreferenceManager +import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.databinding.ActivityMainV2Binding import kr.co.vividnext.sodalive.explorer.profile.creator_community.all.CreatorCommunityAllActivity import kr.co.vividnext.sodalive.main.EventPopupDialogFragment @@ -72,6 +76,7 @@ class MainV2Activity : BaseActivity(ActivityMainV2Binding private var mediaControllerFuture: ListenableFuture? = null private val handler = Handler(Looper.getMainLooper()) private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() } + private val audioContentReceiver = AudioContentReceiver() private var playerStateJob: Job? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -82,11 +87,16 @@ class MainV2Activity : BaseActivity(ActivityMainV2Binding trackAppLaunchIfNeeded() pushTokenUpdate() + handler.postDelayed({ + if (!handleAudioNotificationRoute(intent) && isLoggedIn()) { + executeDeeplink(intent) + } + }, 1000) + if (isLoggedIn()) { updatePidAndGaid() getEventPopup() observePlayerState() - handler.postDelayed({ executeDeeplink(intent) }, 1000) } } @@ -114,14 +124,22 @@ class MainV2Activity : BaseActivity(ActivityMainV2Binding return } - if (isLoggedIn()) { + if (!handleAudioNotificationRoute(intent) && isLoggedIn()) { executeDeeplink(intent) } } + @SuppressLint("UnspecifiedRegisterReceiverFlag") override fun onResume() { super.onResume() getMemberInfo() + val intentFilter = IntentFilter(Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(audioContentReceiver, intentFilter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(audioContentReceiver, intentFilter) + } + startService( Intent(this, AudioContentPlayService::class.java).apply { action = AudioContentPlayService.MusicAction.INIT.name @@ -129,6 +147,11 @@ class MainV2Activity : BaseActivity(ActivityMainV2Binding ) } + override fun onPause() { + unregisterReceiver(audioContentReceiver) + super.onPause() + } + override fun onDestroy() { deInitMiniPlayer() playerStateJob?.cancel() @@ -405,6 +428,39 @@ class MainV2Activity : BaseActivity(ActivityMainV2Binding playerFragment.show(supportFragmentManager, playerFragment.tag) } + private fun handleAudioNotificationRoute(intent: Intent): Boolean { + val route = intent.getStringExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE) ?: return false + intent.removeExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE) + + return when (route) { + ROUTE_AUDIO_PLAYER -> { + if (isLoggedIn()) { + showPlayerFragment() + } else { + showLoginActivity() + } + true + } + + ROUTE_AUDIO_DETAIL -> { + val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0) + intent.removeExtra(Constants.EXTRA_AUDIO_CONTENT_ID) + if (contentId > 0 && isLoggedIn()) { + startActivity( + Intent(applicationContext, AudioContentDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId) + } + ) + } else if (!isLoggedIn()) { + showLoginActivity() + } + contentId > 0 + } + + else -> false + } + } + private fun checkPermissions() { val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO) @@ -734,8 +790,90 @@ class MainV2Activity : BaseActivity(ActivityMainV2Binding return SharedPreferenceManager.token.isNotBlank() && SharedPreferenceManager.token.length > 10 } + inner class AudioContentReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val contentId = intent?.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0) + val title = intent?.getStringExtra(Constants.EXTRA_AUDIO_CONTENT_TITLE) + val nickname = intent?.getStringExtra(Constants.EXTRA_NICKNAME) + val coverImageUrl = intent?.getStringExtra( + Constants.EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL + ) + val isPlaying = intent?.getBooleanExtra(Constants.EXTRA_AUDIO_CONTENT_PLAYING, false) + val isShowing = intent?.getBooleanExtra(Constants.EXTRA_AUDIO_CONTENT_SHOWING, false) + + if (isShowing == true) { + binding.clMiniPlayer.visibility = View.VISIBLE + if (contentId != null && contentId > 0) { + binding.clMiniPlayer.setOnClickListener { + startActivity( + Intent(applicationContext, AudioContentDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId) + } + ) + } + } + + if (isPlaying == true) { + binding.ivPlayerPlayOrPause.setImageResource(R.drawable.ic_noti_pause) + binding.ivPlayerPlayOrPause.setOnClickListener { + startService( + Intent(this@MainV2Activity, AudioContentPlayService::class.java).apply { + action = AudioContentPlayService.MusicAction.PAUSE.name + } + ) + } + } else { + binding.ivPlayerPlayOrPause.setImageResource(R.drawable.ic_noti_play) + binding.ivPlayerPlayOrPause.setOnClickListener { + startService( + Intent(this@MainV2Activity, AudioContentPlayService::class.java).apply { + action = AudioContentPlayService.MusicAction.PLAY.name + } + ) + } + } + + binding.ivPlayerStop.setOnClickListener { + startService( + Intent(this@MainV2Activity, AudioContentPlayService::class.java).apply { + action = AudioContentPlayService.MusicAction.STOP.name + } + ) + } + + if (!title.isNullOrBlank()) { + binding.tvPlayerTitle.text = title + } + + if (!nickname.isNullOrBlank()) { + binding.tvPlayerNickname.text = nickname + } + + if (!coverImageUrl.isNullOrBlank()) { + binding.ivPlayerCover.load(coverImageUrl) { + crossfade(true) + placeholder(R.drawable.bg_placeholder) + transformations(RoundedCornersTransformation(5.3f.dpToPx())) + } + } + } else { + handler.post { + binding.ivPlayerPlayOrPause.setImageResource(0) + binding.ivPlayerCover.setImageResource(0) + binding.tvPlayerTitle.text = "" + binding.tvPlayerNickname.text = "" + binding.clMiniPlayer.visibility = View.GONE + binding.ivPlayerPlayOrPause.setOnClickListener {} + } + } + } + } + companion object { private const val EXTRA_CHAT_FILTER: String = "extra_chat_filter" + const val EXTRA_AUDIO_NOTIFICATION_ROUTE: String = "extra_audio_notification_route" + const val ROUTE_AUDIO_PLAYER: String = "audio_player" + const val ROUTE_AUDIO_DETAIL: String = "audio_detail" fun newChatDmIntent(context: Context): Intent { return Intent(context, MainV2Activity::class.java).apply { diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt index 79d12c0c..afe7d1b6 100644 --- a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt @@ -91,6 +91,34 @@ class MainV2ActivitySourceTest { assertBefore(openContentSource, "changeFragment(MainV2Tab.CONTENT)", "?.selectAllTab(type, sort)") } + @Test + fun `MainV2Activity는 기존 오디오 서비스 브로드캐스트로 미니 플레이어를 표시한다`() { + val source = projectFile("app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt").readText() + + assertTrue(source.contains("import android.content.BroadcastReceiver")) + assertTrue(source.contains("import android.content.IntentFilter")) + assertTrue(source.contains("private val audioContentReceiver = AudioContentReceiver()")) + assertTrue(source.contains("IntentFilter(Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER)")) + assertTrue(source.contains("registerReceiver(audioContentReceiver, intentFilter")) + assertTrue(source.contains("unregisterReceiver(audioContentReceiver)")) + assertTrue(source.contains("inner class AudioContentReceiver : BroadcastReceiver()")) + + val receiverSource = source.substringFrom("inner class AudioContentReceiver : BroadcastReceiver()") + assertTrue(receiverSource.contains("Constants.EXTRA_AUDIO_CONTENT_SHOWING")) + assertTrue(receiverSource.contains("Constants.EXTRA_AUDIO_CONTENT_ID")) + assertTrue(receiverSource.contains("Constants.EXTRA_AUDIO_CONTENT_TITLE")) + assertTrue(receiverSource.contains("Constants.EXTRA_NICKNAME")) + assertTrue(receiverSource.contains("Constants.EXTRA_AUDIO_CONTENT_COVER_IMAGE_URL")) + assertTrue(receiverSource.contains("Constants.EXTRA_AUDIO_CONTENT_PLAYING")) + assertTrue(receiverSource.contains("binding.clMiniPlayer.visibility = View.VISIBLE")) + assertTrue(receiverSource.contains("AudioContentDetailActivity::class.java")) + assertTrue(receiverSource.contains("putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId)")) + assertTrue(receiverSource.contains("AudioContentPlayService.MusicAction.PAUSE.name")) + assertTrue(receiverSource.contains("AudioContentPlayService.MusicAction.PLAY.name")) + assertTrue(receiverSource.contains("AudioContentPlayService.MusicAction.STOP.name")) + assertTrue(receiverSource.contains("binding.clMiniPlayer.visibility = View.GONE")) + } + private fun assertBefore(source: String, expectedBefore: String, expectedAfter: String) { val beforeIndex = source.indexOf(expectedBefore) val afterIndex = source.indexOf(expectedAfter, beforeIndex) diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2AudioNotificationRouteSourceTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2AudioNotificationRouteSourceTest.kt new file mode 100644 index 00000000..27e7c2b9 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2AudioNotificationRouteSourceTest.kt @@ -0,0 +1,67 @@ +package kr.co.vividnext.sodalive.v2.main + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class MainV2AudioNotificationRouteSourceTest { + + private val source = projectFile( + "app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt" + ).readText() + + @Test + fun `오디오 알림 route는 플레이어와 상세페이지로 분기한다`() { + assertTrue(source.contains("const val EXTRA_AUDIO_NOTIFICATION_ROUTE")) + assertTrue(source.contains("const val ROUTE_AUDIO_PLAYER")) + assertTrue(source.contains("const val ROUTE_AUDIO_DETAIL")) + assertTrue(source.contains("handleAudioNotificationRoute")) + assertTrue(source.contains("showPlayerFragment()")) + assertTrue(source.contains("AudioContentDetailActivity::class.java")) + assertTrue(source.contains("Constants.EXTRA_AUDIO_CONTENT_ID")) + assertTrue(source.contains("removeExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE)")) + } + + @Test + fun `오디오 알림 route는 채팅 필터 이후 딥링크 이전에 처리된다`() { + val onNewIntentSource = source.substringFrom("override fun onNewIntent(intent: Intent)") + assertBefore(onNewIntentSource, "if (intent.hasExtra(EXTRA_CHAT_FILTER))", "handleAudioNotificationRoute(intent)") + assertBefore(onNewIntentSource, "handleAudioNotificationRoute(intent)", "isLoggedIn()") + assertBefore(onNewIntentSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)") + + val onCreateSource = source.substringFrom("override fun onCreate(savedInstanceState: Bundle?)") + assertBefore(onCreateSource, "handleAudioNotificationRoute(intent)", "isLoggedIn()") + assertBefore(onCreateSource, "handleAudioNotificationRoute(intent)", "executeDeeplink(intent)") + } + + @Test + fun `오디오 상세 route는 유효한 content id만 상세페이지로 이동한다`() { + val routeSource = source.substringFrom("private fun handleAudioNotificationRoute(intent: Intent): Boolean") + assertTrue(routeSource.contains("ROUTE_AUDIO_DETAIL ->")) + assertTrue(routeSource.contains("val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0)")) + assertTrue(routeSource.contains("intent.removeExtra(Constants.EXTRA_AUDIO_CONTENT_ID)")) + assertTrue(routeSource.contains("if (contentId > 0")) + assertBefore(routeSource, "if (contentId > 0", "AudioContentDetailActivity::class.java") + } + + private fun assertBefore(source: String, expectedBefore: String, expectedAfter: String) { + val beforeIndex = source.indexOf(expectedBefore) + val afterIndex = source.indexOf(expectedAfter, beforeIndex) + + assertTrue("Missing source: $expectedBefore", beforeIndex >= 0) + assertTrue("Missing source after $expectedBefore: $expectedAfter", afterIndex > beforeIndex) + } + + private fun String.substringFrom(marker: String): String { + val startIndex = indexOf(marker) + assertTrue("Missing function: $marker", startIndex >= 0) + val nextFunctionIndex = indexOf("\n private fun ", startIndex + marker.length).takeIf { it >= 0 } ?: length + return substring(startIndex, nextFunctionIndex) + } + + private fun projectFile(relativePath: String): File { + val candidates = listOf(File(relativePath), File("../$relativePath")) + return candidates.firstOrNull { it.exists() } + ?: error("Project file not found: $relativePath") + } +}