diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..c4e4683 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +BCSD_Android_2025-1 \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..97626ba --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..c224ad5 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.kotlin/errors/errors-1751209781864.log b/.kotlin/errors/errors-1751209781864.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/.kotlin/errors/errors-1751209781864.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/.kotlin/errors/errors-1751268238484.log b/.kotlin/errors/errors-1751268238484.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/.kotlin/errors/errors-1751268238484.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5791375..81271fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,11 +1,12 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { namespace = "com.example.bcsd_android_2025_1" - compileSdk = 34 + compileSdk = 35 defaultConfig { applicationId = "com.example.bcsd_android_2025_1" @@ -33,6 +34,9 @@ android { kotlinOptions { jvmTarget = "11" } + buildFeatures { + compose = true + } } dependencies { @@ -42,7 +46,18 @@ dependencies { implementation(libs.material) implementation(libs.androidx.activity) implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c80941..781f6ec 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,11 @@ + + + + + - + + \ No newline at end of file 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 3ffa0eb..64d8f6a 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,14 +1,232 @@ package com.example.bcsd_android_2025_1 - +import android.Manifest +import android.content.BroadcastReceiver +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.net.Uri +import android.os.BatteryManager +import android.os.Build import android.os.Bundle -import androidx.activity.enableEdgeToEdge +import android.provider.Settings +import android.view.View +import android.widget.Button +import android.widget.TextView +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 android.provider.MediaStore +import androidx.compose.ui.unit.Constraints +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import androidx.core.app.ActivityCompat +import android.widget.Toast +import android.util.Log class MainActivity : AppCompatActivity() { + + private lateinit var settingsBtn: Button + private lateinit var explanationText: TextView + private lateinit var nowsongText: TextView + + private val musicUpdateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val title = intent?.getStringExtra(MusicPlayerService.SONG_TITLE) ?: return + Log.d("MainActivity", "Music update received: $title") + findViewById(R.id.textview_nowsong).text = title + } + } + + private val batteryReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1 + val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1 + + if (level >= 0 && scale > 0) { + val batterysize = level * 100 / scale + if (batterysize <= 15) { + Toast.makeText(context, R.string.battery_msg, Toast.LENGTH_SHORT).show() + stopService(Intent(context, MusicPlayerService::class.java)) + } + } + } + } + + private val permission = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + Manifest.permission.READ_MEDIA_AUDIO + else + Manifest.permission.READ_EXTERNAL_STORAGE + + private val requestPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> + if (isGranted) { + showMusicList() + } else { + if (shouldShowRequestPermissionRationale(permission)) { + showPermissionDialog() + } else { + showSettingsButton() + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + + settingsBtn = findViewById(R.id.button_goto_settings) + explanationText = findViewById(R.id.textview_permission) + nowsongText = findViewById(R.id.textview_nowsong) + + settingsBtn.setOnClickListener { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.parse("package:$packageName") + } + startActivity(intent) + } + requestNotificationPermission() + checkPermissionAndRequest() + + val batteryFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) + registerReceiver(batteryReceiver, batteryFilter) + } + + override fun onResume() { + super.onResume() + val filter = IntentFilter(MusicPlayerService.MUSIC_UPDATE) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver( + musicUpdateReceiver, + filter, + null, + null, + Context.RECEIVER_NOT_EXPORTED + ) + } else { + registerReceiver(musicUpdateReceiver, filter) + } + } + override fun onPause() { + super.onPause() + unregisterReceiver(musicUpdateReceiver) + } + + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(batteryReceiver) + unregisterReceiver(musicUpdateReceiver) } -} \ No newline at end of file + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + 2001 + ) + } + } + } + + private fun checkPermissionAndRequest() { + if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) { + showMusicList() + } else { + requestPermission.launch(permission) + } + } + + private fun showPermissionDialog() { + AlertDialog.Builder(this) + .setTitle(R.string.permdialog_title) + .setMessage(R.string.permdialog_msg) + .setPositiveButton(R.string.permdialog_pos) { _, _ -> + requestPermission.launch(permission) + } + .setNegativeButton(R.string.permdialog_neg, null) + .show() + } + + private fun showSettingsButton() { + explanationText.visibility = View.VISIBLE + settingsBtn.visibility = View.VISIBLE + } + + private fun showMusicList() { + lifecycleScope.launch { + val musicList = withContext(Dispatchers.IO) { + loadMusicFromMediaStore() + } + val recyclerView = findViewById(R.id.recycler_view) + recyclerView.adapter = MusicAdapter(musicList) { musicInfo -> + val intent = Intent(this@MainActivity, MusicPlayerService::class.java).apply { + putExtra(MusicPlayerService.SONG_TITLE, musicInfo.title) + putExtra(MusicPlayerService.SONG_URI, musicInfo.uri.toString()) + } + ContextCompat.startForegroundService(this@MainActivity, intent) + } + recyclerView.layoutManager = LinearLayoutManager(this@MainActivity) + } + } + + data class MusicInfo( + val title: String, + val artist: String, + val time: Long, + val uri: Uri + ) + + private fun loadMusicFromMediaStore(): List { + val musicList = mutableListOf() + + val projection = arrayOf( + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.TITLE, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.DURATION + ) + + val selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0" + val sortOrder = "${MediaStore.Audio.Media.TITLE} ASC" + + val cursor = contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + null, + sortOrder + ) + + cursor?.use { + val idIndex = it.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) + val titleIndex = it.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) + val artistIndex = it.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) + val timeIndex = it.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + + while (it.moveToNext()) { + val id = it.getLong(idIndex) + val title = it.getString(titleIndex) ?: "제목없음" + val artist = it.getString(artistIndex) ?: "가수없음" + val time = it.getLong(timeIndex) + val contentUri = + ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id) + musicList.add(MusicInfo(title, artist, time, contentUri)) + } + } + return musicList + } +} + 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..22a2024 --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/MusicAdapter.kt @@ -0,0 +1,49 @@ +package com.example.bcsd_android_2025_1 +import android.content.Intent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.AdapterView.OnItemClickListener +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +class MusicAdapter( + private val musicList: List, + private val itemClickListener: (MainActivity.MusicInfo) -> Unit) : + RecyclerView.Adapter() { + + inner class MusicViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val title = view.findViewById(R.id.tv_title) + val artist = view.findViewById(R.id.tv_artist) + val time = view.findViewById(R.id.tv_time) + + init { + view.setOnClickListener { + val pos = adapterPosition + if (pos != RecyclerView.NO_POSITION){ + itemClickListener(musicList[pos]) + } + } + } + } + + 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 onBindViewHolder(holder: MusicViewHolder, position: Int) { + val music = musicList[position] + holder.title.text = music.title + holder.artist.text = music.artist + holder.time.text = music.time.toTimeString() + } + + override fun getItemCount() = musicList.size + + private fun Long.toTimeString(): String { + val min = this / 1000 / 60 + val sec = (this / 1000 % 60) + return String.format("%02d:%02d", min, sec) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/MusicPlayerService.kt b/app/src/main/java/com/example/bcsd_android_2025_1/MusicPlayerService.kt new file mode 100644 index 0000000..40daf2b --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/MusicPlayerService.kt @@ -0,0 +1,124 @@ +package com.example.bcsd_android_2025_1 +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.media.MediaPlayer +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import android.content.pm.ServiceInfo +import android.util.Log + +class MusicPlayerService : Service() { + + private var mediaPlayer: MediaPlayer? = null + private val CHANNEL_ID = "music_playback_channel" + + companion object { + const val MUSIC_UPDATE = "com.example.bcsd_android_2025_1.MUSIC_UPDATE" + const val SONG_TITLE = "SONG_TITLE" + const val SONG_URI = "SONG_URI" + const val NOTIFICATION_ID = 1 + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val songUriString = intent?.getStringExtra(SONG_URI) ?: return START_NOT_STICKY + val songTitle = intent.getStringExtra(SONG_TITLE) ?: "Unknown" + val songUri = Uri.parse(songUriString) + + mediaPlayer?.release() + mediaPlayer = MediaPlayer().apply { + setOnPreparedListener{ + start() + Log.d("MusicPlayerService", "Starting playback of: $songTitle") + val updateIntent = Intent(MUSIC_UPDATE) + updateIntent.setPackage(packageName) + updateIntent.putExtra(SONG_TITLE, songTitle) + sendBroadcast(updateIntent) + } + setOnCompletionListener { + stopSelf() + } + setOnErrorListener { _, _, _ -> + stopSelf() + true + } + } + + try { + mediaPlayer?.setDataSource(applicationContext, songUri) + val notification = buildNotification(songTitle) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + 1, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(1, notification) + } + + mediaPlayer?.prepareAsync() + } catch (e: Exception) { + e.printStackTrace() + stopSelf() + return START_NOT_STICKY + } + return START_STICKY + } + + private fun buildNotification(songTitle: String): Notification { + val notificationIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = PendingIntent.getActivity( + this, + 0, + notificationIntent, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + else + PendingIntent.FLAG_UPDATE_CURRENT + ) + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("현재 재생 음악") + .setContentText(songTitle) + .setSmallIcon(android.R.drawable.ic_media_play) + .setOngoing(true) + .setContentIntent(pendingIntent) + .setOnlyAlertOnce(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Music Playback", + NotificationManager.IMPORTANCE_DEFAULT + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + override fun onDestroy() { + mediaPlayer?.release() + mediaPlayer = null + super.onDestroy() + } + override fun onBind(intent: Intent?): IBinder? = null +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Color.kt b/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Color.kt new file mode 100644 index 0000000..e897c79 --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.example.bcsd_android_2025_1.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Theme.kt b/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Theme.kt new file mode 100644 index 0000000..9a49c51 --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.example.bcsd_android_2025_1.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun BCSD_Android_20251Theme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Type.kt b/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Type.kt new file mode 100644 index 0000000..e350104 --- /dev/null +++ b/app/src/main/java/com/example/bcsd_android_2025_1/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.example.bcsd_android_2025_1.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 311f3cb..dae3e08 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,54 @@ - + android:layout_height="match_parent"> + + + + + +