Initial commit - v1.1.9

This commit is contained in:
2026-02-22 12:03:04 +09:00
commit 27339dc7b7
180 changed files with 12908 additions and 0 deletions

View File

@@ -0,0 +1,498 @@
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()
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
(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() {
(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
val originalVolume = audioManager.getStreamVolume(AudioManager.STREAM_ALARM)
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_ALARM)
// 알람 볼륨을 최대로 설정 (사용자가 나중에 조정 가능)
try {
audioManager.setStreamVolume(AudioManager.STREAM_ALARM, maxVolume, 0)
} 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")
}
}
}