-
Notifications
You must be signed in to change notification settings - Fork 12
[김석현_ Android] 9주차 과제 제출 #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,185 @@ | ||
| package com.example.bcsd_android_2025_1 | ||
|
|
||
| import android.Manifest | ||
| import android.content.Intent | ||
| import android.content.pm.PackageManager | ||
| import android.database.Cursor | ||
| 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.appcompat.app.AlertDialog | ||
| 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 | ||
| import java.io.File | ||
|
|
||
| data class MusicItem( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 별도의 data class로 분리 바랍니다 |
||
| val id: Long, | ||
| val title: String, | ||
| val artist: String, | ||
| val duration: String, | ||
| val albumId: Long, | ||
| val data: String | ||
| ) | ||
|
|
||
| class MainActivity : AppCompatActivity() { | ||
|
|
||
| private lateinit var recyclerView: RecyclerView | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RecyclerView를 굳이 lateinit으로 선언할 이유가 없어 보입니다. |
||
| 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, "allow", Toast.LENGTH_SHORT).show() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. strings.xml 사용이 필요합니다. |
||
| } 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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. strings.xml 써주세요. |
||
| .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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scoped storage 도입 후, /sdcard에 직접 접근이 불가합니다. |
||
| if (file.exists()) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 파일이 존재하는지 확인해서 미디어 스캐닝을 시도하려고 하신 것 같은데, 이렇게 되면 파일이 존재할 때 미디어 스캐닝이 동작합니다. 미디어 스캐닝을 시도하시려면 미디어 스캐닝을 시도하는 버튼을 만드시는게 좋아보입니다. 미디어 스캐닝을 앱 실행 시 매번 실행하면 되는거 아니냐고 생각하실 수 있는데, 미디어 스캐닝 자체가 꽤 무거운 작업이라 필요할 때만 실행해야 합니다. |
||
| MediaScannerConnection.scanFile(this, arrayOf(file.absolutePath), null, null) | ||
| } | ||
|
|
||
| if (file.exists()) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 마찬가지입니다 |
||
| 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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| 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() | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<MusicItem> | ||
| ) : RecyclerView.Adapter<MusicAdapter.MusicViewHolder>() { | ||
|
|
||
| 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| Uri.parse("content://media/external/audio/albumart"), | ||
| musicItem.albumId | ||
| ) | ||
|
|
||
| Glide.with(holder.itemView.context) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Glide를 썼는데, dependency에 추가가 안되어 있네요 |
||
| .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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <shape xmlns:android="http://schemas.android.com/apk/res/android" | ||
| android:shape="rectangle"> | ||
| <solid android:color="@color/surface_color" /> | ||
| <corners android:radius="8dp" /> | ||
| <stroke android:width="1dp" android:color="#E0E0E0" /> | ||
| </shape> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <shape xmlns:android="http://schemas.android.com/apk/res/android" | ||
| android:shape="rectangle"> | ||
| <solid android:color="@color/surface_color" /> | ||
| <corners android:radius="8dp" /> | ||
| <stroke android:width="1dp" android:color="#E0E0E0" /> | ||
| </shape> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. recyclerview의 아이템에 사용하는 xml 파일은 activity라는 이름을 가져선 안됩니다. |
||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:orientation="horizontal" | ||
| android:padding="12dp" | ||
| android:background="?android:attr/selectableItemBackground" | ||
| android:gravity="center_vertical"> | ||
|
|
||
| <ImageView | ||
| android:id="@+id/iv_album_art" | ||
| android:layout_width="56dp" | ||
| android:layout_height="56dp" | ||
| android:src="@drawable/ic_music_note" | ||
| android:scaleType="centerCrop" | ||
| android:background="@drawable/album_art_background" /> | ||
|
|
||
| <LinearLayout | ||
| android:layout_width="0dp" | ||
| android:layout_height="wrap_content" | ||
| android:layout_weight="1" | ||
| android:orientation="vertical" | ||
| android:layout_marginStart="16dp" | ||
| android:layout_marginEnd="8dp"> | ||
|
|
||
| <TextView | ||
| android:id="@+id/tv_title" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:text="title" | ||
| android:textSize="16sp" | ||
| android:textStyle="bold" | ||
| android:textColor="@color/primary_text" | ||
| android:maxLines="1" | ||
| android:ellipsize="end" /> | ||
|
|
||
| <TextView | ||
| android:id="@+id/tv_artist" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="wrap_content" | ||
| android:text="artist" | ||
| android:textSize="14sp" | ||
| android:textColor="@color/secondary_text" | ||
| android:layout_marginTop="2dp" | ||
| android:maxLines="1" | ||
| android:ellipsize="end" /> | ||
|
|
||
| </LinearLayout> | ||
|
|
||
| <TextView | ||
| android:id="@+id/tv_duration" | ||
| android:layout_width="wrap_content" | ||
| android:layout_height="wrap_content" | ||
| android:text="00:00" | ||
| android:textSize="12sp" | ||
| android:textColor="@color/secondary_text" | ||
| android:layout_marginStart="8dp" /> | ||
|
|
||
| </LinearLayout> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. activity_permission이 아니라 activity_main에 있어야 하는 내용입니다. |
||
| <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" | ||
| xmlns:tools="http://schemas.android.com/tools" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="match_parent" | ||
| android:orientation="vertical" | ||
| android:background="@color/background_color" | ||
| tools:context=".MainActivity"> | ||
|
|
||
| <androidx.appcompat.widget.Toolbar | ||
| android:layout_width="match_parent" | ||
| android:layout_height="?attr/actionBarSize" | ||
| android:background="@color/primary_color" | ||
| android:elevation="4dp"> | ||
|
|
||
| <TextView | ||
| android:layout_width="wrap_content" | ||
| android:layout_height="wrap_content" | ||
| android:text="음악" | ||
| android:textColor="@android:color/white" | ||
| android:textSize="20sp" | ||
| android:textStyle="bold" /> | ||
|
|
||
| </androidx.appcompat.widget.Toolbar> | ||
|
|
||
| <androidx.recyclerview.widget.RecyclerView | ||
| android:id="@+id/recycler_view_music" | ||
| android:layout_width="match_parent" | ||
| android:layout_height="match_parent" | ||
| android:padding="8dp" | ||
| tools:listitem="@layout/activity_music" /> | ||
|
|
||
| </LinearLayout> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
불필요한 권한입니다.