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
43 changes: 27 additions & 16 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.kapt")
}

android {
namespace = "com.example.bcsd_android_2025_1"
compileSdk = 34


defaultConfig {
applicationId = "com.example.bcsd_android_2025_1"
minSdk = 26
Expand All @@ -22,27 +24,36 @@ android {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17 // ← 스튜디오 Giraffe 이후 기본
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "11"
kotlinOptions { jvmTarget = "17" }

buildFeatures {
viewBinding = true
}
}

dependencies {
/* --- AndroidX 기본 --- */
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.activity:activity-ktx:1.9.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")

implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}
/* --- Glide (앨범아트 로딩) --- */
implementation("com.github.bumptech.glide:glide:4.16.0")
kapt("com.github.bumptech.glide:compiler:4.16.0")

/* --- 테스트 --- */
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.INTERNET" />

<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
Expand Down
263 changes: 258 additions & 5 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,267 @@
package com.example.bcsd_android_2025_1

import android.Manifest
import android.R
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent
import android.content.pm.PackageManager
import android.database.Cursor
import android.media.MediaPlayer
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import android.provider.MediaStore
import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager

import com.example.bcsd_android_2025_1.databinding.ActivityMainBinding
import java.io.File

data class MusicItem(
val id: Long,
val title: String,
val artist: String,
val duration: String,
val albumId: Long,
val data: String
)

class MainActivity : AppCompatActivity() {

private var mediaPlayer: MediaPlayer? = null
private var currentlyPlaying: MusicItem? = null

private lateinit var binding: ActivityMainBinding

private lateinit var musicAdapter: MusicAdapter
private val musicList = mutableListOf<MusicItem>()

private val musicPermission: String
get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_AUDIO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}

private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
loadMusicFiles()
Toast.makeText(this, "Permission granted", Toast.LENGTH_SHORT).show()
Copy link

Choose a reason for hiding this comment

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

Toast message 는 strings.xml 써주세요

} else {
showPermissionDeniedDialog()
}
}



override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

createNotificationChannel()

initRecyclerView()
checkAndRequestPermission()
}

override fun onResume() {
super.onResume()
if (ContextCompat.checkSelfPermission(this, musicPermission) == PackageManager.PERMISSION_GRANTED && musicList.isEmpty()) {
loadMusicFiles()
}
}

override fun onDestroy() {
super.onDestroy()
mediaPlayer?.release()
mediaPlayer = null
}

@SuppressLint("MissingPermission")
private fun initRecyclerView() {
musicAdapter = MusicAdapter(musicList) { clickedItem ->

if (currentlyPlaying?.id == clickedItem.id && mediaPlayer?.isPlaying == true) {
pauseMusic()
} else {
playMusic(clickedItem)
}
}

binding.recyclerViewMusic.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = musicAdapter
setHasFixedSize(true)
}
}

@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
private fun playMusic(item: MusicItem) {
mediaPlayer?.release()
mediaPlayer = MediaPlayer().apply {
setDataSource(item.data)
prepare()
start()
setOnCompletionListener {
Toast.makeText(
this@MainActivity,
"\"${item.title}\" music end.",
Toast.LENGTH_SHORT
).show()
currentlyPlaying = null
}
}
currentlyPlaying = item
Toast.makeText(this, "\"${item.title}\" playing now", Toast.LENGTH_SHORT).show()
Copy link

Choose a reason for hiding this comment

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

마찬가지 입니다

showNowPlayingNotification(item)
}

private fun pauseMusic() {
mediaPlayer?.pause()
Toast.makeText(this, "일시정지", Toast.LENGTH_SHORT).show()
Copy link

Choose a reason for hiding this comment

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

마찬가지 입니다

}


private fun checkAndRequestPermission() {
if (ContextCompat.checkSelfPermission(this, musicPermission) == PackageManager.PERMISSION_GRANTED) {
loadMusicFiles()
} else {
showPermissionRequestDialog()
}
}
}

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"music_channel",
"channel",
NotificationManager.IMPORTANCE_LOW
).apply { description = "음악 재생 상태 표시" }

val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}

@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
private fun showNowPlayingNotification(item: MusicItem) {
val notification = NotificationCompat.Builder(this, "music_channel")
.setSmallIcon(R.drawable.ic_media_play)
.setContentTitle(item.title)
.setContentText("artist: ${item.artist}")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()

NotificationManagerCompat.from(this).notify(1, notification)
}

private fun showPermissionRequestDialog() {
AlertDialog.Builder(this)
.setTitle("음악 및 오디오 접근 권한")
.setMessage("음악 파일을 불러오기 위해 권한이 필요합니다.")
.setPositiveButton("허용") { _, _ ->
requestPermissionLauncher.launch(musicPermission)
}
.setNegativeButton("거부") { _, _ ->
showPermissionDeniedDialog()
}
.setCancelable(false)
.show()
}

private fun showPermissionDeniedDialog() {
AlertDialog.Builder(this)
.setTitle("권한 필요")
.setMessage("음악 파일을 표시하려면 권한이 필요합니다.\n\n설정에서 권한을 허용해주세요.")
Copy link

Choose a reason for hiding this comment

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

메시지 모두 strings.xml 이용해주세요

.setPositiveButton("설정 열기") { _, _ ->
openAppSettings()
}
.setNegativeButton("종료") { _, _ ->
finish()
}
.setCancelable(false)
.show()
}

private fun openAppSettings() {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)
}

private fun loadMusicFiles() {
val file = File("/sdcard/Music/Lil_Tecca_Dark_Thoughts.mp3", "/sdcard/Music/Frank_Ocean_Pink_+_White.mp3")

if (file.exists()) {
MediaScannerConnection.scanFile(this, arrayOf(file.absolutePath), null, null)
}

musicList.clear()

val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.DATA
)

val selection = "${MediaStore.Audio.Media.IS_MUSIC} = 1"
val sortOrder = "${MediaStore.Audio.Media.TITLE} ASC"

val cursor: Cursor? = contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
null,
sortOrder
)

cursor?.use {
val idColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val titleColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val artistColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
val durationColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val albumIdColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
val dataColumn = it.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)

while (it.moveToNext()) {
val id = it.getLong(idColumn)
val title = it.getString(titleColumn) ?: "Unknown Title"
Copy link

Choose a reason for hiding this comment

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

이런 부분도 전부 strings.xml 이용해주세요
사용자에게 보이는 부분은 모두 strings.xml 에 넣어주는게 좋습니다.
이 외에도 "" 를 이용하는게 아닌 Constant 를 하나 만들어 상수를 모아 만든 후
해당 함수를 참조하는 형태로 Activity 나 viewModel 에서는 참조의 형태가 좋습니다.

val artist = it.getString(artistColumn) ?: "Unknown Artist"
val duration = formatDuration(it.getLong(durationColumn))
val albumId = it.getLong(albumIdColumn)
val data = it.getString(dataColumn) ?: ""

musicList.add(MusicItem(id, title, artist, duration, albumId, data))
}
}

musicAdapter.notifyDataSetChanged()
}

private fun formatDuration(durationMs: Long): String {
val minutes = (durationMs / 1000) / 60
val seconds = (durationMs / 1000) % 60
return String.format("%02d:%02d", minutes, seconds)
}


}
Loading