package com.example.shiftalarm import android.app.KeyguardManager import android.content.Context import android.content.Intent import android.media.AudioAttributes import android.media.AudioManager import android.media.MediaPlayer import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.PowerManager import android.os.VibrationEffect import android.os.Vibrator import android.util.Log import android.view.MotionEvent import android.view.View import android.view.WindowManager import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.activity.enableEdgeToEdge import com.example.shiftalarm.databinding.ActivityAlarmBinding import androidx.core.content.ContextCompat import java.time.LocalDate import java.time.format.DateTimeFormatter import kotlin.math.abs class AlarmActivity : AppCompatActivity() { private lateinit var binding: ActivityAlarmBinding private var mediaPlayer: MediaPlayer? = null private var vibrator: Vibrator? = null private var startX = 0f // 5분 후 자동 스누즈 private val autoStopHandler = Handler(Looper.getMainLooper()) private val AUTO_STOP_DELAY = 5L * 60 * 1000 override fun onCreate(savedInstanceState: Bundle?) { // 중요: 잠금 화면 위 표시 설정을 가장 먼저 적용 setupLockScreenFlags() super.onCreate(savedInstanceState) // ForegroundService가 실행 중이면 먼저 중지 stopService(Intent(this, AlarmForegroundService::class.java)) // Service 중지 후 약간의 지연을 두어 AudioFocus가 완전히 해제되도록 함 try { Thread.sleep(100) } catch (e: InterruptedException) { // 무시 } enableEdgeToEdge() binding = ActivityAlarmBinding.inflate(layoutInflater) binding.root.background = ContextCompat.getDrawable(this, R.drawable.bg_alarm_gradient) setContentView(binding.root) // 추가 윈도우 플래그 설정 setupWindowFlags() val shift = intent.getStringExtra("EXTRA_SHIFT") ?: "근무" binding.tvShiftType.text = if (shift == "SNOOZE") "다시 울림 알람" else "[$shift] 근무 알람" val now = java.util.Calendar.getInstance() val amPm = if (now.get(java.util.Calendar.AM_PM) == java.util.Calendar.AM) "오전" else "오후" val hour = now.get(java.util.Calendar.HOUR) val hourText = if (hour == 0) 12 else hour val min = now.get(java.util.Calendar.MINUTE) binding.tvCurrentTime.text = String.format("%s %d:%02d", amPm, hourText, min) val today = LocalDate.now() val dayOfWeek = today.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, java.util.Locale.KOREAN) binding.tvDate.text = String.format("%d월 %d일 %s", today.monthValue, today.dayOfMonth, dayOfWeek) // 알람 시작 (화면 상태와 무관하게 항상 실행) startAlarm() // 마스터 알람이 꺼져있으면 알람 화면을 즉시 종료 val prefs = getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { Toast.makeText(this, "전체 알람이 꺼져있습니다.", Toast.LENGTH_SHORT).show() finish() return } setupControls() // 5분 후 자동 스누즈 autoStopHandler.postDelayed({ Toast.makeText(this, "알람이 자동으로 다시 울림 설정되었습니다.", Toast.LENGTH_LONG).show() snoozeAlarm() stopAlarm() finish() }, AUTO_STOP_DELAY) // 키가드(잠금화면) 상태 변화 리스너 등록 registerKeyguardListener() } /** * 잠금 화면 관련 플래그를 super.onCreate 이전에 설정 */ private fun setupLockScreenFlags() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { setShowWhenLocked(true) setTurnScreenOn(true) } } private fun setupWindowFlags() { // ======================================== // 알람 화면이 패턴/지문보다 먼저 표시되도록 설정 // ======================================== // 중요: requestDismissKeyguard()를 호출하면 패턴/지문이 먼저 뜸 // 알람 화면을 먼저 띄우려면 FLAG_SHOW_WHEN_LOCKED만 사용해야 함 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { // 1. 가장 먼저: 화면 켜기 + 잠금화면 위에 표시 setShowWhenLocked(true) setTurnScreenOn(true) } // 2. 화면 켜짐 유지 window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) // 3. 하위 호환성: Android 8.0 이하 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) { @Suppress("DEPRECATION") window.addFlags( WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON ) } // 4. Android 14+ 추가 플래그 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_OVERSCAN) window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) } // 5. Android 10+ 레이아웃 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { window.addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) } // 6. 전체화면 모드 (모든 기기 공통) setFullscreenMode() } /** * 전체화면 모드 설정 */ private fun isSamsungDevice(): Boolean { val manufacturer = Build.MANUFACTURER?.lowercase() ?: "" val brand = Build.BRAND?.lowercase() ?: "" return manufacturer.contains("samsung") || brand.contains("samsung") } /** * 전체화면 모드 설정 */ private fun setFullscreenMode() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { // Android 11+ (API 30+): WindowInsetsController 사용 window.setDecorFitsSystemWindows(false) window.insetsController?.let { controller -> controller.hide(android.view.WindowInsets.Type.statusBars() or android.view.WindowInsets.Type.navigationBars()) controller.systemBarsBehavior = android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } else { // Android 10 이하: systemUiVisibility 사용 @Suppress("DEPRECATION") window.decorView.systemUiVisibility = ( View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION ) } } private fun setupControls() { binding.btnSnooze.setOnClickListener { handleSnooze() } // Swipe-to-dismiss for Stop Button var startX = 0f val dismissBtn = binding.btnDismiss val maxSwipe = dpToPx(100f).toFloat() dismissBtn.setOnTouchListener { v, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { startX = event.rawX v.animate().cancel() true } MotionEvent.ACTION_MOVE -> { val dx = event.rawX - startX val clampedDx = if (dx > 0) dx.coerceAtMost(maxSwipe) else dx.coerceAtLeast(-maxSwipe) v.translationX = clampedDx // Visual feedback: scale up when near trigger val ratio = abs(clampedDx) / maxSwipe v.scaleX = 1f + (ratio * 0.15f) v.scaleY = 1f + (ratio * 0.15f) v.alpha = 1f - (ratio * 0.3f) true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { val dx = event.rawX - startX if (abs(dx) > maxSwipe * 0.8f) { // Trigger Dismiss @Suppress("DEPRECATION") (getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(50) Toast.makeText(this, "알람 해제 완료", Toast.LENGTH_SHORT).show() stopAlarm(); finish() } else { // Reset v.animate() .translationX(0f) .scaleX(1f) .scaleY(1f) .alpha(1f) .setDuration(300) .start() } true } else -> false } } // Pulse logic with enhanced glow fun startPulse() { binding.pulseCircle.scaleX = 0.85f; binding.pulseCircle.scaleY = 0.85f; binding.pulseCircle.alpha = 0.5f binding.pulseCircle.animate() .scaleX(1.3f).scaleY(1.3f).alpha(1.0f) .setDuration(1500) .withEndAction { binding.pulseCircle.animate() .scaleX(0.85f).scaleY(0.85f).alpha(0.5f) .setDuration(1500) .withEndAction { if(!isFinishing) startPulse() } .start() } .start() } startPulse() } private fun handleSnooze() { @Suppress("DEPRECATION") (getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator)?.vibrate(50) val snoozeRepeat = intent.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) val text = if (snoozeRepeat == 99) "다시 울림 설정됨" else "다시 울림 (${snoozeRepeat}회 남음)" Toast.makeText(this, text, Toast.LENGTH_SHORT).show() snoozeAlarm(); stopAlarm(); finish() } private fun dpToPx(dp: Float): Int { return (dp * resources.displayMetrics.density).toInt() } private fun startAlarm() { if (mediaPlayer?.isPlaying == true) { Log.d("AlarmActivity", "MediaPlayer가 이미 실행 중") return } val soundUriStr = intent.getStringExtra("EXTRA_SOUND") val alarmUri = if (!soundUriStr.isNullOrEmpty()) { Uri.parse(soundUriStr) } else { val prefs = getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) val globalUriStr = prefs.getString("alarm_uri", null) if (globalUriStr != null) Uri.parse(globalUriStr) else android.provider.Settings.System.DEFAULT_ALARM_ALERT_URI } // AudioAttributes 강화: 무음/진동 모드에서도 알람음이 울리도록 val audioAttrs = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ALARM) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) .build() // AudioManager를 통해 알람 볼륨 설정 및 무음 모드 우회 val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager // 무음 모드에서도 알람음이 울리도록 STREAM_ALARM 사용 (벨소리와 독립) // 알람 스트림은 다른 스트림과 달리 무음 모드에서도 울림 val originalRingerMode = audioManager.ringerMode // 알람 볼륨을 최대로 설정 (사용자가 나중에 조정 가능) try { val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_ALARM) audioManager.setStreamVolume(AudioManager.STREAM_ALARM, maxVolume, 0) Log.d("AlarmActivity", "알람 볼륨 설정: $maxVolume (RingerMode: $originalRingerMode)") } catch (e: Exception) { Log.w("AlarmActivity", "알람 볼륨 설정 실패", e) } var mediaPlayerStarted = false try { mediaPlayer?.release() mediaPlayer = MediaPlayer().apply { setAudioAttributes(audioAttrs) setDataSource(this@AlarmActivity, alarmUri!!) isLooping = true setVolume(1.0f, 1.0f) // 최대 볼륨 prepare() start() } mediaPlayerStarted = true Log.d("AlarmActivity", "MediaPlayer 시작 성공 (사용자 지정음)") } catch (e: Exception) { Log.e("AlarmActivity", "MediaPlayer 시작 실패 (사용자 지정음), fallback 시도", e) // Fallback 1: 시스템 기본 알람음 try { val fallback = android.provider.Settings.System.DEFAULT_ALARM_ALERT_URI mediaPlayer = MediaPlayer().apply { setAudioAttributes(audioAttrs) setDataSource(this@AlarmActivity, fallback) isLooping = true setVolume(1.0f, 1.0f) prepare() start() } mediaPlayerStarted = true Log.d("AlarmActivity", "MediaPlayer 시작 성공 (Fallback 1: 시스템 기본)") } catch (e2: Exception) { Log.e("AlarmActivity", "Fallback 1 실패", e2) // Fallback 2: RingtoneManager에서 기본 알람 가져오기 try { val ringtoneUri = android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_ALARM) mediaPlayer = MediaPlayer().apply { setAudioAttributes(audioAttrs) setDataSource(this@AlarmActivity, ringtoneUri) isLooping = true setVolume(1.0f, 1.0f) prepare() start() } mediaPlayerStarted = true Log.d("AlarmActivity", "MediaPlayer 시작 성공 (Fallback 2: RingtoneManager)") } catch (e3: Exception) { Log.e("AlarmActivity", "모든 MediaPlayer 시작 실패", e3) } } } // 진동 시작 (알람음과 독립적으로 - 알람음 실패필도 진동은 울림) vibrator = getSystemService(Context.VIBRATOR_SERVICE) as Vibrator try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 1000, 500, 1000), 0) vibrator?.vibrate(vibrationEffect) } else { @Suppress("DEPRECATION") vibrator?.vibrate(longArrayOf(0, 1000, 500, 1000), 0) } Log.d("AlarmActivity", "진동 시작 성공") } catch (e: Exception) { Log.e("AlarmActivity", "진동 시작 실패", e) } // 알람음 시작 실패 시 토스트 메시지 if (!mediaPlayerStarted) { Toast.makeText(this, "알람음 재생에 실패했습니다. 진동으로 알려드립니다.", Toast.LENGTH_LONG).show() } } private fun snoozeAlarm() { val snoozeMin = intent.getIntExtra("EXTRA_SNOOZE", 5) val snoozeRepeat = intent.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) val soundUriStr = intent.getStringExtra("EXTRA_SOUND") if (snoozeRepeat > 0) { val nextRepeat = if (snoozeRepeat == 99) 99 else snoozeRepeat - 1 scheduleSnooze(this, snoozeMin, soundUriStr, nextRepeat) } else { Toast.makeText(this, "다시 울림 횟수를 모두 소모하여 알람을 종료합니다.", Toast.LENGTH_SHORT).show() } } private fun stopAlarm() { stopService(Intent(this, AlarmForegroundService::class.java)) try { mediaPlayer?.let { if (it.isPlaying) it.stop() it.release() } } catch (e: Exception) {} mediaPlayer = null try { vibrator?.cancel() } catch (e: Exception) {} vibrator = null val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager nm.cancel(1) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) val shift = intent.getStringExtra("EXTRA_SHIFT") ?: "근무" binding.tvShiftType.text = if (shift == "SNOOZE") "다시 울림 알람" else "[$shift] 근무 알람" stopAlarm() startAlarm() } override fun onAttachedToWindow() { super.onAttachedToWindow() // 화면 켜짐 및 잠금 화면 위 표시 재적용 setupWindowFlags() } override fun onDestroy() { super.onDestroy() autoStopHandler.removeCallbacksAndMessages(null) stopAlarm() unregisterKeyguardListener() } // ======================================== // 키가드(잠금화면) 상태 감지 및 알람 해제 처리 // ======================================== private var keyguardManager: KeyguardManager? = null private var keyguardCallback: KeyguardManager.KeyguardDismissCallback? = null /** * 키가드(잠금화면) 상태 변화를 감지하여 알람을 적절히 처리 * 안드로이드 버전별로 다른 방식으로 처리 */ private fun registerKeyguardListener() { keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Android 8.0+: KeyguardDismissCallback 사용 keyguardCallback = object : KeyguardManager.KeyguardDismissCallback() { override fun onDismissError() { Log.e("AlarmActivity", "Keyguard dismiss error") } override fun onDismissSucceeded() { Log.d("AlarmActivity", "Keyguard dismissed successfully - 사용자가 패턴/지문으로 해제함") // 패턴/지문 해제 후 알람 계속 울리게 하려면 여기서 아무것도 하지 않음 // 알람을 자동으로 멈추려면: stopAlarm(); finish() } override fun onDismissCancelled() { Log.d("AlarmActivity", "Keyguard dismiss cancelled") } } } } private fun unregisterKeyguardListener() { keyguardCallback = null } /** * 현재 키가드(잠금화면)가 잠겨있는지 확인 */ private fun isKeyguardLocked(): Boolean { return keyguardManager?.isKeyguardLocked ?: false } /** * 현재 키가드(잠금화면)가 보안 잠금(패턴/PIN/지문)을 사용하는지 확인 */ private fun isKeyguardSecure(): Boolean { return keyguardManager?.isKeyguardSecure ?: false } override fun onPause() { super.onPause() // 홈 버튼이나 다른 앱으로 전환 시 알람 계속 울리도록 함 // 사용자가 의도적으로 알람을 해제하지 않았으므로 Log.d("AlarmActivity", "onPause - 알람 계속 유지") } override fun onStop() { super.onStop() // 알람 화면이 백그라운드로 갔을 때 // 잠금화면이 다시 잠기면 알람을 멈추지 않고 계속 유지 Log.d("AlarmActivity", "onStop - 알람 계속 유지") } override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus) { // 알람 화면이 다시 포커스를 받으면 전체화면 모드 재적용 setFullscreenMode() Log.d("AlarmActivity", "Window focus regained") } } }