Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/AndroidProjectSystem.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions .idea/migrations.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions .idea/runConfigurations.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

167 changes: 161 additions & 6 deletions app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,169 @@
package com.example.bcsd_android_2025_1

import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import android.Manifest
import android.content.*
import android.net.Uri
import android.os.*
import android.provider.MediaStore
import android.view.View
import android.widget.Button
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

class MainActivity : AppCompatActivity(), MusicAdapter.OnItemClickListener {

private lateinit var recyclerView: RecyclerView
private lateinit var permissionMessage: TextView
private lateinit var openSettingsButton: Button
private lateinit var requestPermissionButton: Button
private lateinit var musicAdapter: MusicAdapter
private val musicList = mutableListOf<MusicData>()

private var musicService: MusicService? = null
private var isBound = false

private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) showMusicList()
else showPermissionUI()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 코드에서는 뷰를 조절하는 것으로 권한 관련 UI를 표시하는 것으로 보입니다 visible로 조절하는 것 보다는 shouldShowRequestPermissionRationale() 등으로 분기처리 해주시는 것을 권장드립니다

}

private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as MusicService.MusicBinder
musicService = binder.getService()
isBound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
musicService = null
isBound = false
}
}

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

recyclerView = findViewById(R.id.MusicRecyclerView)
permissionMessage = findViewById(R.id.text1)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메인 xml layout에서는 text1이 없는 것으로 보이는 데 어떤 텍스트뷰일까요?

openSettingsButton = findViewById(R.id.OpenButton)
requestPermissionButton = findViewById(R.id.RequestButton)

musicAdapter = MusicAdapter(musicList, this)
recyclerView.adapter = musicAdapter
recyclerView.layoutManager = LinearLayoutManager(this)

recyclerView.visibility = View.GONE
permissionMessage.visibility = View.GONE
openSettingsButton.visibility = View.GONE
requestPermissionButton.visibility = View.GONE

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위의 뷰를 초기화하는 4개 줄은 xml에서 visibility에 초기값을 주는 방식으로 개선해도 괜찮을 것으로 보입니다


if (hasPermission()) showMusicList()
else showPermissionUI()

requestPermissionButton.setOnClickListener { requestPermission() }
openSettingsButton.setOnClickListener {
val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", packageName, null)
intent.data = uri
startActivity(intent)
}
}

override fun onStart() {
super.onStart()
val intent = Intent(this, MusicService::class.java)
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}

override fun onStop() {
super.onStop()
if (isBound) {
unbindService(connection)
isBound = false
}
}

override fun onResume() {
super.onResume()
if (hasPermission()) showMusicList()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oncreate에서 해당 로직을 사용하셨는데 onResume에서도 사용하는 이유가 있을까요? 생명주기에 다시 생각해보셨으면 좋겠습니다.

}

private fun hasPermission(): Boolean {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
Manifest.permission.READ_MEDIA_AUDIO
else
Manifest.permission.READ_EXTERNAL_STORAGE
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}

private fun requestPermission() {
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
Manifest.permission.READ_MEDIA_AUDIO
else
Manifest.permission.READ_EXTERNAL_STORAGE
permissionLauncher.launch(permission)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

권한 체크하는 로직이 중복되었네요.


private fun showPermissionUI() {
recyclerView.visibility = View.GONE
permissionMessage.visibility = View.VISIBLE
openSettingsButton.visibility = View.VISIBLE
requestPermissionButton.visibility = View.VISIBLE
}

private fun showMusicList() {
recyclerView.visibility = View.VISIBLE
permissionMessage.visibility = View.GONE
openSettingsButton.visibility = View.GONE
requestPermissionButton.visibility = View.GONE

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visibility를 변경하는 로직은 따로 빼주는 게 좋을 것 같네요 중복되는 부분이 많습니다.

loadMusicList()
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

showPermissionUI와 showMusicList에서 UI 관련 코드가 중복되었는데 이럴때는 따로 확장 함수로 구현하시는 것을 추천드립니다


private fun loadMusicList() {
musicList.clear()
val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.DURATION
)
val cursor = contentResolver.query(
uri,
projection,
MediaStore.Audio.Media.IS_MUSIC + "!=0",
null,
MediaStore.Audio.Media.TITLE + " ASC"
)
cursor?.use {
val idIdx = it.getColumnIndex(MediaStore.Audio.Media._ID)
val titleIdx = it.getColumnIndex(MediaStore.Audio.Media.TITLE)
val artistIdx = it.getColumnIndex(MediaStore.Audio.Media.ARTIST)
val durationIdx = it.getColumnIndex(MediaStore.Audio.Media.DURATION)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getColumnIndex는 해당 column이 없을 경우 -1을 반환하는 함수입니다 이때에 대한 예외 처리 로직도 추가해주시면 좋을 것 같습니다

while (it.moveToNext()) {
val id = it.getLong(idIdx)
val title = it.getString(titleIdx) ?: "Unknown"
val artist = it.getString(artistIdx) ?: "Unknown"
val duration = it.getLong(durationIdx)
val contentUri = ContentUris.withAppendedId(uri, id)
musicList.add(MusicData(title, artist, duration, contentUri))
}
}
musicAdapter.notifyDataSetChanged()
}

override fun onItemClick(music: MusicData) {
val intent = Intent(this, MusicService::class.java).apply {
putExtra("music_uri", music.uri.toString())
putExtra("music_title", music.title)
}
ContextCompat.startForegroundService(this, intent)
if (isBound) musicService?.playMusic(music.uri, music.title)
}
}
}
48 changes: 48 additions & 0 deletions app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.example.bcsd_android_2025_1

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class MusicAdapter(
private val musicList: List<MusicData>,
private val listener: OnItemClickListener
) : RecyclerView.Adapter<MusicAdapter.MusicViewHolder>() {

interface OnItemClickListener {
fun onItemClick(music: MusicData)
}

class MusicViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val titleText: TextView = view.findViewById(R.id.textTitle)
val artistText: TextView = view.findViewById(R.id.textArtist)
val durationText: TextView = view.findViewById(R.id.textDuration)
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MusicViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_music, parent, false)
return MusicViewHolder(view)
}

override fun getItemCount() = musicList.size

override fun onBindViewHolder(holder: MusicViewHolder, position: Int) {
val music = musicList[position]
holder.titleText.text = music.title
holder.artistText.text = music.artist
holder.durationText.text = formatDuration(music.duration)
holder.itemView.setOnClickListener {
listener.onItemClick(music)
}
}

private fun formatDuration(durationMs: Long): String {
val totalSec = durationMs / 1000
val min = totalSec / 60
val sec = totalSec % 60
return String.format("%02d:%02d", min, sec)
}
}
10 changes: 10 additions & 0 deletions app/src/main/java/com/example/bcsd_android_2025_1/MusicItem.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.bcsd_android_2025_1

import android.net.Uri

data class MusicData(
val title: String,
val artist: String,
val duration: Long,
val uri: Uri
)
63 changes: 63 additions & 0 deletions app/src/main/java/com/example/bcsd_android_2025_1/MusicService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.example.bcsd_android_2025_1

import android.app.*
import android.content.Intent
import android.media.MediaPlayer
import android.net.Uri
import android.os.Binder
import android.os.IBinder
import androidx.core.app.NotificationCompat

class MusicService : Service() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
class MusicService : Service() {
class MusicService : BindService() {


private val binder = MusicBinder()
private var mediaPlayer: MediaPlayer? = null

inner class MusicBinder : Binder() {
fun getService(): MusicService = this@MusicService
}

override fun onBind(intent: Intent?): IBinder = binder

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val uriString = intent?.getStringExtra("music_uri")
val title = intent?.getStringExtra("music_title") ?: "재생 중"
if (uriString != null) {
playMusic(Uri.parse(uriString), title)
}
return START_NOT_STICKY
}

fun playMusic(uri: Uri, title: String) {
mediaPlayer?.release()
mediaPlayer = MediaPlayer.create(this, uri).apply {
setOnCompletionListener {
stopSelf()
stopForeground(STOP_FOREGROUND_REMOVE)
}
start()
}
showNotification(title)
}

private fun showNotification(title: String) {
val notifIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this, 0, notifIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, "music_channel")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

채널 id는 상수를 사용해주세요.

.setContentTitle("음악 재생")
.setContentText(title)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
startForeground(1, notification)
}

override fun onDestroy() {
mediaPlayer?.release()
mediaPlayer = null
stopForeground(STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
}
Binary file added app/src/main/res/drawable/ic_logo_google.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/src/main/res/drawable/ic_logo_kakao.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/src/main/res/drawable/ic_logo_naver.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading