From da249bdeb22139195240fc4cffd23a176aa45f73 Mon Sep 17 00:00:00 2001 From: kimseokhyeon1210 Date: Tue, 24 Jun 2025 03:31:55 +0900 Subject: [PATCH 1/2] 9 assignment --- app/src/main/AndroidManifest.xml | 6 + .../bcsd_android_2025_1/MainActivity.kt | 179 +++++++++++++++++- .../bcsd_android_2025_1/MusicAdapter.kt | 55 ++++++ .../res/drawable/album_art_background.xml | 7 + app/src/main/res/drawable/ic_music_note.xml | 7 + app/src/main/res/layout/activity_music.xml | 59 ++++++ .../main/res/layout/activity_permission.xml | 33 ++++ app/src/main/res/values/colors.xml | 11 ++ app/src/main/res/values/strings.xml | 25 ++- 9 files changed, 377 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt create mode 100644 app/src/main/res/drawable/album_art_background.xml create mode 100644 app/src/main/res/drawable/ic_music_note.xml create mode 100644 app/src/main/res/layout/activity_music.xml create mode 100644 app/src/main/res/layout/activity_permission.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c80941..dec17f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + + + + () + + + 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, "allow", Toast.LENGTH_SHORT).show() + } else { + showPermissionDeniedDialog() + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + initViews() + checkAndRequestPermission() + } + + private fun initViews() { + recyclerView = findViewById(R.id.recycler_view_music) + musicAdapter = MusicAdapter(musicList) + recyclerView.apply { + layoutManager = LinearLayoutManager(this@MainActivity) + adapter = musicAdapter + } + } + + private fun checkAndRequestPermission() { + if (ContextCompat.checkSelfPermission(this, musicPermission) == PackageManager.PERMISSION_GRANTED) { + loadMusicFiles() + } else { + showPermissionRequestDialog() + } + } + + private fun showPermissionRequestDialog() { + AlertDialog.Builder(this) + .setTitle("음악 및 오디오 접근 권한") + .setMessage("음악 파일을 불러오기 위해 권한이 필요합니다.") + .setPositiveButton("Allow") { _, _ -> + requestPermissionLauncher.launch(musicPermission) + } + .setNegativeButton("Don't allow") { _, _ -> + showPermissionDeniedDialog() + } + .setCancelable(false) + .show() + } + + private fun showPermissionDeniedDialog() { + AlertDialog.Builder(this) + .setTitle("권한 필요!") + .setMessage("음악 파일을 표시하려면 권한이 필요합니다.\n\nAllow permission to show files") + .setPositiveButton("Open Settings") { _, _ -> + openAppSettings() + } + .setNegativeButton("Cancel") { _, _ -> + 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") + if (file.exists()) { + MediaScannerConnection.scanFile(this, arrayOf(file.absolutePath), null, null) + } + + if (file.exists()) { + val uri = Uri.fromFile(file) + sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri)) + } + + 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" + 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) + } + + override fun onResume() { + super.onResume() + if (ContextCompat.checkSelfPermission(this, musicPermission) == PackageManager.PERMISSION_GRANTED && musicList.isEmpty()) { + loadMusicFiles() + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt b/app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt new file mode 100644 index 0000000..f65ad9a --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt @@ -0,0 +1,55 @@ +package com.example.bcsd_android_2025_1 + +import android.content.ContentUris +import android.net.Uri +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.bumptech.glide.request.RequestOptions + +class MusicAdapter( + private val musicList: List +) : RecyclerView.Adapter() { + + inner class MusicViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val albumArt: ImageView = itemView.findViewById(R.id.iv_album_art) + val title: TextView = itemView.findViewById(R.id.tv_title) + val artist: TextView = itemView.findViewById(R.id.tv_artist) + val duration: TextView = itemView.findViewById(R.id.tv_duration) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MusicViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.activity_music, parent, false) + return MusicViewHolder(view) + } + + override fun onBindViewHolder(holder: MusicViewHolder, position: Int) { + val musicItem = musicList[position] + + holder.title.text = musicItem.title + holder.artist.text = musicItem.artist + holder.duration.text = musicItem.duration + + val albumArtUri = ContentUris.withAppendedId( + Uri.parse("content://media/external/audio/albumart"), + musicItem.albumId + ) + + Glide.with(holder.itemView.context) + .load(albumArtUri) + .apply( + RequestOptions() + .placeholder(R.drawable.ic_music_note) + .error(R.drawable.ic_music_note) + .centerCrop() + ) + .into(holder.albumArt) + } + + override fun getItemCount(): Int = musicList.size +} diff --git a/app/src/main/res/drawable/album_art_background.xml b/app/src/main/res/drawable/album_art_background.xml new file mode 100644 index 0000000..b41af79 --- /dev/null +++ b/app/src/main/res/drawable/album_art_background.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_music_note.xml b/app/src/main/res/drawable/ic_music_note.xml new file mode 100644 index 0000000..b41af79 --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_music.xml b/app/src/main/res/layout/activity_music.xml new file mode 100644 index 0000000..8a43ee9 --- /dev/null +++ b/app/src/main/res/layout/activity_music.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_permission.xml b/app/src/main/res/layout/activity_permission.xml new file mode 100644 index 0000000..f187a69 --- /dev/null +++ b/app/src/main/res/layout/activity_permission.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index c8524cd..4c3cc91 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,4 +2,15 @@ #FF000000 #FFFFFFFF + #4CAF50 + #03DAC5 + #018786 + #6200EE + #3700B3 + #FF9800 + #757575 + #212121 + #F5F5F5 + #FFFFFF + #6200EE \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6c4daf..9a9db08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,26 @@ - + BCSD_Android_2025-1 + First Fragment + Second Fragment + Next + Previous + more + back + Alarm + choice + random + text + Result: %1$d + Music Player + 음악 및 오디오 접근 권한 + Allow BCSD_Android_2025-1 to access music and audio on this device? + Allow + Don\'t allow + Permission Required! + Press OK to allow permission! + 권한 필요! + 음악 파일을 표시하려면 권한이 필요합니다.\n\nAllow permission to show files + Open Settings + Cancel + OK \ No newline at end of file From d08c70e0ed85b4d414cee5ec24821e812d623be1 Mon Sep 17 00:00:00 2001 From: kimseokhyeon1210 Date: Tue, 1 Jul 2025 02:07:35 +0900 Subject: [PATCH 2/2] 10 assignment --- app/build.gradle.kts | 43 ++++-- .../bcsd_android_2025_1/MainActivity.kt | 138 ++++++++++++++---- .../bcsd_android_2025_1/MusicAdapter.kt | 8 +- app/src/main/res/layout/activity_main.xml | 38 +++-- gradle/libs.versions.toml | 3 + 5 files changed, 173 insertions(+), 57 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5791375..c635930 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 @@ -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) -} \ No newline at end of file + /* --- 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") +} diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt b/app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt index d1eb28a..f0dd2d1 100644 --- a/app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt +++ b/app/src/main/java/com/example/bcsd_android_2025_1/MainActivity.kt @@ -1,9 +1,14 @@ 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 @@ -12,11 +17,15 @@ 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.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView + +import com.example.bcsd_android_2025_1.databinding.ActivityMainBinding import java.io.File data class MusicItem( @@ -30,11 +39,14 @@ data class MusicItem( class MainActivity : AppCompatActivity() { - private lateinit var recyclerView: RecyclerView + private var mediaPlayer: MediaPlayer? = null + private var currentlyPlaying: MusicItem? = null + + private lateinit var binding: ActivityMainBinding + private lateinit var musicAdapter: MusicAdapter private val musicList = mutableListOf() - private val musicPermission: String get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_AUDIO @@ -47,29 +59,83 @@ class MainActivity : AppCompatActivity() { ) { isGranted -> if (isGranted) { loadMusicFiles() - Toast.makeText(this, "allow", Toast.LENGTH_SHORT).show() + Toast.makeText(this, "Permission granted", Toast.LENGTH_SHORT).show() } else { showPermissionDeniedDialog() } } + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(R.layout.activity_main) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) - initViews() + createNotificationChannel() + + initRecyclerView() checkAndRequestPermission() } - private fun initViews() { - recyclerView = findViewById(R.id.recycler_view_music) - musicAdapter = MusicAdapter(musicList) - recyclerView.apply { + 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() + showNowPlayingNotification(item) + } + + private fun pauseMusic() { + mediaPlayer?.pause() + Toast.makeText(this, "일시정지", Toast.LENGTH_SHORT).show() } + private fun checkAndRequestPermission() { if (ContextCompat.checkSelfPermission(this, musicPermission) == PackageManager.PERMISSION_GRANTED) { loadMusicFiles() @@ -78,14 +144,40 @@ class MainActivity : AppCompatActivity() { } } + 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("Allow") { _, _ -> + .setPositiveButton("허용") { _, _ -> requestPermissionLauncher.launch(musicPermission) } - .setNegativeButton("Don't allow") { _, _ -> + .setNegativeButton("거부") { _, _ -> showPermissionDeniedDialog() } .setCancelable(false) @@ -94,12 +186,12 @@ class MainActivity : AppCompatActivity() { private fun showPermissionDeniedDialog() { AlertDialog.Builder(this) - .setTitle("권한 필요!") - .setMessage("음악 파일을 표시하려면 권한이 필요합니다.\n\nAllow permission to show files") - .setPositiveButton("Open Settings") { _, _ -> + .setTitle("권한 필요") + .setMessage("음악 파일을 표시하려면 권한이 필요합니다.\n\n설정에서 권한을 허용해주세요.") + .setPositiveButton("설정 열기") { _, _ -> openAppSettings() } - .setNegativeButton("Cancel") { _, _ -> + .setNegativeButton("종료") { _, _ -> finish() } .setCancelable(false) @@ -114,17 +206,12 @@ class MainActivity : AppCompatActivity() { } private fun loadMusicFiles() { + val file = File("/sdcard/Music/Lil_Tecca_Dark_Thoughts.mp3", "/sdcard/Music/Frank_Ocean_Pink_+_White.mp3") - val file = File("/sdcard/Music/Lil_Tecca_Dark_Thoughts.mp3") if (file.exists()) { MediaScannerConnection.scanFile(this, arrayOf(file.absolutePath), null, null) } - if (file.exists()) { - val uri = Uri.fromFile(file) - sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri)) - } - musicList.clear() val projection = arrayOf( @@ -176,10 +263,5 @@ class MainActivity : AppCompatActivity() { return String.format("%02d:%02d", minutes, seconds) } - override fun onResume() { - super.onResume() - if (ContextCompat.checkSelfPermission(this, musicPermission) == PackageManager.PERMISSION_GRANTED && musicList.isEmpty()) { - loadMusicFiles() - } - } + } diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt b/app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt index f65ad9a..1f920ba 100644 --- a/app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt +++ b/app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt @@ -1,5 +1,6 @@ package com.example.bcsd_android_2025_1 + import android.content.ContentUris import android.net.Uri import android.view.LayoutInflater @@ -12,7 +13,8 @@ import com.bumptech.glide.Glide import com.bumptech.glide.request.RequestOptions class MusicAdapter( - private val musicList: List + private val musicList: List, + private val onItemClick: (MusicItem) -> Unit ) : RecyclerView.Adapter() { inner class MusicViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { @@ -35,6 +37,10 @@ class MusicAdapter( holder.artist.text = musicItem.artist holder.duration.text = musicItem.duration + holder.itemView.setOnClickListener { + onItemClick(musicItem) + } + val albumArtUri = ContentUris.withAppendedId( Uri.parse("content://media/external/audio/albumart"), musicItem.albumId diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 311f3cb..9cd8ad2 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,33 @@ - - + - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 283fec9..91001f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ appcompat = "1.7.0" material = "1.12.0" activity = "1.9.3" constraintlayout = "2.2.1" +glide = "4.14.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -19,6 +20,8 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } +glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }