1 Commits

Author SHA1 Message Date
380b2c5a6c refactor: 알람 시스템 단순화 - 기본 알람 제거, ID 체계 개선
- 기본 알람(legacy) 로직 완전 제거
- 알람 ID 체계 단순화: alarmDbId * 100 + dayOffset
- 예약 범위 축소: 오늘/내일만 예약 (복잡한 30일/365일 예약 제거)
- AlarmSyncManager 단순화: 즉시 반영 로직
- AlarmReceiver 강화: 삭제된 알람 필터링 개선
- suspend 함수로 변경하여 비동기 처리 개선

Fixes: 삭제된 알람이 울리는 문제 해결

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-28 18:10:46 +09:00
5 changed files with 502 additions and 429 deletions

View File

@@ -13,7 +13,7 @@ class AlarmReceiver : BroadcastReceiver() {
private val TAG = "AlarmReceiver" private val TAG = "AlarmReceiver"
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
Log.d(TAG, "===== 알람 수신 (Receiver) =====") Log.d(TAG, "===== 알람 수신 =====")
// 마스터 알람이 꺼져있으면 알람 무시 // 마스터 알람이 꺼져있으면 알람 무시
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
@@ -22,36 +22,69 @@ class AlarmReceiver : BroadcastReceiver() {
return return
} }
val alarmId = intent?.getIntExtra("EXTRA_ALARM_ID", -1) ?: -1 val action = intent?.action
val isCustom = intent?.getBooleanExtra("EXTRA_IS_CUSTOM", false) ?: false
// 커스텀 알람인 경우 DB에서 여전히 유효한지 확인 (삭제된 알람이 울리는 문제 해결) when (action) {
if (isCustom && alarmId != -1) { "com.example.shiftalarm.SNOOZE" -> {
val customAlarmId = intent.getIntExtra("EXTRA_UNIQUE_ID", -1) // 스누즈 알람
if (customAlarmId != -1) { startAlarm(context, intent)
// 비동기로 DB 확인 }
val scope = CoroutineScope(Dispatchers.IO) else -> {
scope.launch { // 일반 알람 - DB에서 여전히 유효한지 확인
val repo = ShiftRepository(context) val alarmDbId = intent?.getIntExtra("EXTRA_ALARM_DB_ID", -1) ?: -1
val alarms = repo.getAllCustomAlarms()
val alarmExists = alarms.any { it.id == customAlarmId && it.isEnabled }
if (!alarmExists) { if (alarmDbId != -1) {
Log.w(TAG, "삭제된 또는 비활성화된 알람입니다. 무시합니다. (ID: $customAlarmId)") // 비동기로 DB 확인
scope.cancel() val scope = CoroutineScope(Dispatchers.IO)
return@launch scope.launch {
try {
val repo = ShiftRepository(context)
val alarms = repo.getAllCustomAlarms()
val alarm = alarms.find { it.id == alarmDbId }
// 알람이 존재하지 않거나 비활성화되었으면 무시
if (alarm == null) {
Log.w(TAG, "DB에서 알람을 찾을 수 없음 (삭제됨): ID=$alarmDbId")
scope.cancel()
return@launch
}
if (!alarm.isEnabled) {
Log.w(TAG, "알람이 비활성화됨: ID=$alarmDbId")
scope.cancel()
return@launch
}
// 근무 조건 확인
val dateStr = intent?.getStringExtra("EXTRA_DATE")
if (dateStr != null) {
val date = java.time.LocalDate.parse(dateStr)
val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val shift = repo.getShift(date, team, factory)
if (alarm.shiftType != "기타" && alarm.shiftType != shift) {
Log.w(TAG, "근무 조건이 맞지 않음: 알람=${alarm.shiftType}, 실제=$shift")
scope.cancel()
return@launch
}
}
// 모든 검증 통과 - 알람 실행
startAlarm(context, intent)
scope.cancel()
} catch (e: Exception) {
Log.e(TAG, "알람 검증 중 오류", e)
scope.cancel()
}
} }
} else {
// 알람이 유효하면 직접 AlarmActivity 실행 + Foreground Service 시작 // DB ID가 없는 경우 (테스트 알람 등) - 바로 실행
startAlarm(context, intent) startAlarm(context, intent)
scope.cancel()
} }
return
} }
} }
// 일반 알람은 바로 직접 실행
startAlarm(context, intent)
} }
private fun startAlarm(context: Context, intent: Intent?) { private fun startAlarm(context: Context, intent: Intent?) {
@@ -62,15 +95,20 @@ class AlarmReceiver : BroadcastReceiver() {
PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP, PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
"ShiftAlarm::AlarmWakeLock" "ShiftAlarm::AlarmWakeLock"
) )
wakeLock.acquire(30 * 1000L) // 30초 - Activity 실행 및 초기화에 충분한 시간 wakeLock.acquire(30 * 1000L) // 30초
try { try {
// 1. Foreground Service 시작 (알림 표시 및 시스템에 알람 실행 중 알림) val shiftType = intent?.getStringExtra("EXTRA_SHIFT_TYPE") ?: "근무"
val soundUri = intent?.getStringExtra("EXTRA_SOUND")
val snoozeMin = intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5
val snoozeRepeat = intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3
// 1. Foreground Service 시작
val serviceIntent = Intent(context, AlarmForegroundService::class.java).apply { val serviceIntent = Intent(context, AlarmForegroundService::class.java).apply {
putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무") putExtra("EXTRA_SHIFT", shiftType)
putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND")) putExtra("EXTRA_SOUND", soundUri)
putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5) putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3) putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -81,28 +119,24 @@ class AlarmReceiver : BroadcastReceiver() {
Log.d(TAG, "ForegroundService 시작 완료") Log.d(TAG, "ForegroundService 시작 완료")
// 2. AlarmActivity 직접 실행 (알람 화면 표시) // 2. AlarmActivity 직접 실행
val activityIntent = Intent(context, AlarmActivity::class.java).apply { val activityIntent = Intent(context, AlarmActivity::class.java).apply {
putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무") putExtra("EXTRA_SHIFT", shiftType)
putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND")) putExtra("EXTRA_SOUND", soundUri)
putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5) putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3) putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
// 중요: 새 태스크로 실행 (FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// 기존 인스턴스 재사용 및 최상위로 가져오기
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
// 잠금 화면 위에 표시
addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT) addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT)
// 화면 켜기
addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS)
} }
// 지연 후 Activity 시작 (ForegroundService가 알림을 먼저 표시하도록) // 지연 후 Activity 시작 (ForegroundService가 먼저 시작되도록)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
try { try {
context.startActivity(activityIntent) context.startActivity(activityIntent)
Log.d(TAG, "AlarmActivity 실행 완료") Log.d(TAG, "AlarmActivity 실행 완료: $shiftType")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "AlarmActivity 실행 실패", e) Log.e(TAG, "AlarmActivity 실행 실패", e)
} }
@@ -111,7 +145,7 @@ class AlarmReceiver : BroadcastReceiver() {
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 실행 실패", e) Log.e(TAG, "알람 실행 실패", e)
} finally { } finally {
// WakeLock은 Activity가 화면을 켜고 나서 해제 // WakeLock 해제
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
if (wakeLock.isHeld) wakeLock.release() if (wakeLock.isHeld) wakeLock.release()
}, 5000) }, 5000)

View File

@@ -4,25 +4,22 @@ import android.content.Context
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.LocalDate
/** /**
* 알람 동기화 관리자 * 단순화된 알람 동기화 관리자
* DB와 AlarmManager 간의 실시간 동기화를 보장합니다.
* *
* 동기화 전략: * 핵심 원칙:
* 1. DB 작업과 AlarmManager 작업을 원자적으로 처리 * 1. 알람 추가/삭제/수정 시 즉시 AlarmManager에 반영
* 2. 실패 시 롤백 메커니즘 제공 * 2. 복잡한 예약 취소 로직 제거 - 단순한 ID 체계 사용
* 3. 동기화 상태 추적 및 재시도 * 3. 매일 자정 WorkManager가 다음날 알람 스케줄링
*/ */
object AlarmSyncManager { object AlarmSyncManager {
private const val TAG = "AlarmSyncManager" private const val TAG = "AlarmSyncManager"
private const val PREFS_NAME = "AlarmSyncPrefs"
/** /**
* 알람 추가 동기화 * 알람 추가
* DB에 추가 후 AlarmManager에 즉시 예약 * DB에 추가 후 즉시 AlarmManager에 예약
*/ */
suspend fun addAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) { suspend fun addAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
@@ -32,64 +29,32 @@ object AlarmSyncManager {
val alarmId = repo.addCustomAlarm(alarm) val alarmId = repo.addCustomAlarm(alarm)
Log.d(TAG, "알람 DB 추가 완료: ID=$alarmId") Log.d(TAG, "알람 DB 추가 완료: ID=$alarmId")
// 2. AlarmManager에 예약 // 2. AlarmManager에 즉시 예약
val today = LocalDate.now(SEOUL_ZONE) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val customAlarms = repo.getAllCustomAlarms() val team = prefs.getString("selected_team", "A") ?: "A"
val addedAlarm = customAlarms.find { it.id == alarmId.toInt() } val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
if (addedAlarm == null) { val addedAlarm = alarm.copy(id = alarmId.toInt())
Log.w(TAG, "추가된 알람을 DB에서 찾을 수 없음: ID=$alarmId") scheduleNextAlarm(context, addedAlarm, team, factory)
return@withContext Result.failure(Exception("알람을 찾을 수 없습니다"))
}
if (addedAlarm.isEnabled) {
// 향후 30일치 예약
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate,
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_team", "A") ?: "A",
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (addedAlarm.shiftType == "기타" || addedAlarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
addedAlarm.id,
addedAlarm.shiftType,
addedAlarm.time,
addedAlarm.soundUri,
addedAlarm.snoozeInterval,
addedAlarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 AlarmManager 예약 완료: ID=$alarmId")
}
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_add_alarm", System.currentTimeMillis())
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 추가 동기화 실패", e) Log.e(TAG, "알람 추가 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 알람 수정 동기화 * 알람 수정
* DB 수정 후 기존 AlarmManager 예약 취소 후 재예약 * DB 수정 후 기존 예약 취소 후 재예약
*/ */
suspend fun updateAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) { suspend fun updateAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
val repo = ShiftRepository(context) val repo = ShiftRepository(context)
// 1. 기존 AlarmManager 예약 취소 // 1. 기존 예약 취소
cancelAllCustomAlarmSchedules(context, alarm.id) cancelAlarm(context, alarm.id)
Log.d(TAG, "기존 알람 예약 취소 완료: ID=${alarm.id}") Log.d(TAG, "기존 알람 취소 완료: ID=${alarm.id}")
// 2. DB 업데이트 // 2. DB 업데이트
repo.updateCustomAlarm(alarm) repo.updateCustomAlarm(alarm)
@@ -97,70 +62,45 @@ object AlarmSyncManager {
// 3. 활성화된 알람이면 재예약 // 3. 활성화된 알람이면 재예약
if (alarm.isEnabled) { if (alarm.isEnabled) {
val today = LocalDate.now(SEOUL_ZONE) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
for (i in 0 until 30) { val team = prefs.getString("selected_team", "A") ?: "A"
val targetDate = today.plusDays(i.toLong()) val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val shift = repo.getShift(targetDate,
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_team", "A") ?: "A",
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) { scheduleNextAlarm(context, alarm, team, factory)
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 재예약 완료: ID=${alarm.id}") Log.d(TAG, "알람 재예약 완료: ID=${alarm.id}")
} }
// 4. 동기화 상태 저장
saveSyncStatus(context, "last_update_alarm", System.currentTimeMillis())
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 수정 동기화 실패", e) Log.e(TAG, "알람 수정 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 알람 삭제 동기화 * 알람 삭제
* AlarmManager 예약 먼저 취소 후 DB에서 삭제 * AlarmManager 예약 먼저 취소 후 DB에서 삭제
*/ */
suspend fun deleteAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) { suspend fun deleteAlarm(context: Context, alarm: CustomAlarm): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
val repo = ShiftRepository(context)
// 1. AlarmManager 예약 취소 (DB 삭제 전에 먼저!) // 1. AlarmManager 예약 취소 (DB 삭제 전에 먼저!)
cancelAllCustomAlarmSchedules(context, alarm.id) cancelAlarm(context, alarm.id)
Log.d(TAG, "알람 예약 취소 완료: ID=${alarm.id}") Log.d(TAG, "알람 예약 취소 완료: ID=${alarm.id}")
// 2. DB에서 삭제 // 2. DB에서 삭제
val repo = ShiftRepository(context)
repo.deleteCustomAlarm(alarm) repo.deleteCustomAlarm(alarm)
Log.d(TAG, "알람 DB 삭제 완료: ID=${alarm.id}") Log.d(TAG, "알람 DB 삭제 완료: ID=${alarm.id}")
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_delete_alarm", System.currentTimeMillis())
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 삭제 동기화 실패", e) Log.e(TAG, "알람 삭제 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 알람 토글 동기화 (활성화/비활성화) * 알람 토글 (활성화/비활성화)
*/ */
suspend fun toggleAlarm(context: Context, alarm: CustomAlarm, enable: Boolean): Result<Unit> = withContext(Dispatchers.IO) { suspend fun toggleAlarm(context: Context, alarm: CustomAlarm, enable: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
try { try {
@@ -170,94 +110,67 @@ object AlarmSyncManager {
if (enable) { if (enable) {
// 활성화: DB 업데이트 후 예약 // 활성화: DB 업데이트 후 예약
repo.updateCustomAlarm(updatedAlarm) repo.updateCustomAlarm(updatedAlarm)
val today = LocalDate.now(SEOUL_ZONE)
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate,
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_team", "A") ?: "A",
context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.getString("selected_factory", "Jeonju") ?: "Jeonju"
)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) { val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
scheduleCustomAlarm( val team = prefs.getString("selected_team", "A") ?: "A"
context, val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
targetDate,
alarm.id, scheduleNextAlarm(context, updatedAlarm, team, factory)
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
Log.d(TAG, "알람 활성화 완료: ID=${alarm.id}") Log.d(TAG, "알람 활성화 완료: ID=${alarm.id}")
} else { } else {
// 비활성화: 예약 취소 후 DB 업데이트 // 비활성화: 예약 취소 후 DB 업데이트
cancelAllCustomAlarmSchedules(context, alarm.id) cancelAlarm(context, alarm.id)
repo.updateCustomAlarm(updatedAlarm) repo.updateCustomAlarm(updatedAlarm)
Log.d(TAG, "알람 비활성화 완료: ID=${alarm.id}") Log.d(TAG, "알람 비활성화 완료: ID=${alarm.id}")
} }
saveSyncStatus(context, "last_toggle_alarm", System.currentTimeMillis())
Result.success(Unit) Result.success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "알람 토글 동기화 실패", e) Log.e(TAG, "알람 토글 실패", e)
Result.failure(e) Result.failure(e)
} }
} }
/** /**
* 전체 알람 동기화 (앱 시작 시 호출) * 전체 알람 동기화 (앱 시작/설정 변경 시)
* 깨끗한 상태에서 모든 활성화된 알람 재예약
*/ */
suspend fun syncAllAlarmsWithCheck(context: Context): Result<SyncResult> = withContext(Dispatchers.IO) { suspend fun syncAllAlarmsWithCheck(context: Context): Result<SyncResult> = withContext(Dispatchers.IO) {
try { try {
Log.d(TAG, "전체 알람 동기화 시작") Log.d(TAG, "전체 알람 동기화 시작")
// 1. 기존 모든 알람 취소 val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val repo = ShiftRepository(context) val repo = ShiftRepository(context)
val allAlarms = repo.getAllCustomAlarms() val allAlarms = repo.getAllCustomAlarms()
// 1. 모든 기존 알람 취소
for (alarm in allAlarms) { for (alarm in allAlarms) {
cancelAllCustomAlarmSchedules(context, alarm.id) cancelAlarm(context, alarm.id)
} }
Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}") Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}")
// 2. 활성화된 알람만 재예약 // 2. 마스터 알람이 꺼져있으면 여기서 종료
val enabledAlarms = allAlarms.filter { it.isEnabled } if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
val today = LocalDate.now(SEOUL_ZONE) Log.d(TAG, "마스터 알람 꺼짐, 동기화 종료")
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) return@withContext Result.success(SyncResult(
totalAlarms = allAlarms.size,
enabledAlarms = 0,
scheduledAlarms = 0
))
}
// 3. 활성화된 알람만 재예약
val team = prefs.getString("selected_team", "A") ?: "A" val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val enabledAlarms = allAlarms.filter { it.isEnabled }
var scheduledCount = 0 var scheduledCount = 0
for (alarm in enabledAlarms) { for (alarm in enabledAlarms) {
for (i in 0 until 30) { scheduleNextAlarm(context, alarm, team, factory)
val targetDate = today.plusDays(i.toLong()) scheduledCount++
val shift = repo.getShift(targetDate, team, factory)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
scheduledCount++
}
}
} }
Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람, ${scheduledCount}개 예약") Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람")
// 3. 동기화 상태 저장
saveSyncStatus(context, "last_full_sync", System.currentTimeMillis())
Result.success(SyncResult( Result.success(SyncResult(
totalAlarms = allAlarms.size, totalAlarms = allAlarms.size,
@@ -271,21 +184,77 @@ object AlarmSyncManager {
} }
/** /**
* 동기화 상태 저장 * 특정 날짜 알람 스케줄링 (WorkManager에서 매일 자정 호출)
*/ */
private fun saveSyncStatus(context: Context, key: String, timestamp: Long) { suspend fun scheduleAlarmsForDate(context: Context, date: java.time.LocalDate): Result<Unit> = withContext(Dispatchers.IO) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) try {
.edit() val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
.putLong(key, timestamp)
.apply()
}
/** if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
* 마지막 동기화 시간 확인 return@withContext Result.success(Unit)
*/ }
fun getLastSyncTime(context: Context, key: String): Long {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val repo = ShiftRepository(context)
.getLong(key, 0) val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val alarms = repo.getAllCustomAlarms()
val shift = repo.getShift(date, team, factory)
// 해당 날짜의 기존 알람 모두 취소
for (alarm in alarms) {
val alarmId = getAlarmId(alarm.id, date)
if (alarmId != -1) {
cancelAlarmInternal(context, alarmId)
}
}
// 조건에 맞는 활성화된 알람만 재예약
for (alarm in alarms) {
if (!alarm.isEnabled) continue
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
// 오늘 기준으로 시간이 지났는지 확인
val now = java.time.LocalDateTime.now(SEOUL_ZONE)
val parts = alarm.time.split(":")
val hour = parts[0].toIntOrNull() ?: continue
val minute = parts[1].toIntOrNull() ?: continue
val alarmTime = java.time.LocalDateTime.of(date, java.time.LocalTime.of(hour, minute))
// 오늘이고 이미 지난 시간이면 예약 안함
if (date == java.time.LocalDate.now(SEOUL_ZONE) && alarmTime.isBefore(now)) {
continue
}
val alarmId = getAlarmId(alarm.id, date)
if (alarmId == -1) continue
val intent = android.content.Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER"
putExtra("EXTRA_ALARM_DB_ID", alarm.id)
putExtra("EXTRA_DATE", date.toString())
putExtra("EXTRA_TIME", alarm.time)
putExtra("EXTRA_SHIFT_TYPE", alarm.shiftType)
putExtra("EXTRA_SOUND", alarm.soundUri)
putExtra("EXTRA_SNOOZE", alarm.snoozeInterval)
putExtra("EXTRA_SNOOZE_REPEAT", alarm.snoozeRepeat)
}
val pendingIntent = android.app.PendingIntent.getBroadcast(
context, alarmId, intent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
val triggerTime = alarmTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
setExactAlarm(context, triggerTime, pendingIntent)
}
}
Log.d(TAG, "${date} 알람 스케줄링 완료")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "알람 스케줄링 실패: $date", e)
Result.failure(e)
}
} }
/** /**

View File

@@ -6,201 +6,293 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import android.widget.Toast
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalTime import java.time.LocalTime
import java.time.ZoneId import java.time.ZoneId
import java.util.concurrent.TimeUnit
val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul") val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul")
const val TAG = "ShiftAlarm" const val TAG = "ShiftAlarm"
// ============================================ // ============================================
// 알람 ID 생성 // 알람 ID 생성 - 단순화: alarmId * 10000 + 날짜 기반 오프셋
// ============================================ // ============================================
fun getCustomAlarmId(date: LocalDate, uniqueId: Int): Int { fun getAlarmId(alarmDbId: Int, date: LocalDate): Int {
// Combine date and a unique ID from DB to avoid collisions // alarmDbId: 1, 2, 3...
// Using (uniqueId % 1000) to keep it within a reasonable range // 날짜 오프셋: 오늘=0, 내일=1, ... 6일 후=6 (일주일 단위로 재사용)
return 200000000 + (date.year % 100) * 1000000 + date.monthValue * 10000 + date.dayOfMonth * 100 + (uniqueId % 100) val today = LocalDate.now(SEOUL_ZONE)
val dayOffset = java.time.temporal.ChronoUnit.DAYS.between(today, date).toInt()
// 범위 체크: -30일 ~ +30일 범위 내에서만 유효
if (dayOffset < -30 || dayOffset > 30) {
return -1 // 유효하지 않음
}
// ID 생성: alarmDbId * 100 + dayOffset (음수 처리)
// 예: alarmDbId=5, 오늘(dayOffset=0) -> 500
// 예: alarmDbId=5, 내일(dayOffset=1) -> 501
return alarmDbId * 100 + dayOffset + 30 // +30으로 음수 방지
} }
// ============================================ // ============================================
// 사용자 알람 예약 // 다음 알람 예약 (오늘 또는 내일 중 다음 발생)
// ============================================ // ============================================
fun scheduleCustomAlarm( suspend fun scheduleNextAlarm(
context: Context, context: Context,
date: LocalDate, alarm: CustomAlarm,
uniqueId: Int, team: String,
shiftType: String, factory: String
time: String,
soundUri: String? = null,
snoozeMin: Int = 5,
snoozeRepeat: Int = 3
) { ) {
val alarmId = getCustomAlarmId(date, uniqueId) if (!alarm.isEnabled) {
val label = "사용자:$shiftType" Log.d(TAG, "알람 비활성화됨, 예약 안함: ID=${alarm.id}")
return
val parts = time.split(":")
if (parts.size != 2) return
val hour = parts[0].toIntOrNull() ?: return
val min = parts[1].toIntOrNull() ?: return
cancelAlarmInternal(context, alarmId)
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER"
putExtra("EXTRA_SHIFT", label)
putExtra("EXTRA_DATE", date.toString())
putExtra("EXTRA_TIME", time)
putExtra("EXTRA_ALARM_ID", alarmId)
putExtra("EXTRA_IS_CUSTOM", true)
putExtra("EXTRA_UNIQUE_ID", uniqueId) // DB 검증용
putExtra("EXTRA_SOUND", soundUri)
putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
} }
val pendingIntent = PendingIntent.getBroadcast( val repo = ShiftRepository(context)
context, alarmId, intent, val today = LocalDate.now(SEOUL_ZONE)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE val now = LocalDateTime.now(SEOUL_ZONE)
)
val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, min)) // 시간 파싱
val parts = alarm.time.split(":")
if (parts.size != 2) return
val hour = parts[0].toIntOrNull() ?: return
val minute = parts[1].toIntOrNull() ?: return
// 오늘 날짜의 알람 시간
val todayAlarmTime = LocalDateTime.of(today, LocalTime.of(hour, minute))
// 오늘 근무 확인
val todayShift = repo.getShift(today, team, factory)
// 오늘 알람이 아직 안 울렸고, 근무 조건이 맞으면 오늘 예약
val shouldRingToday = todayAlarmTime.isAfter(now) &&
(alarm.shiftType == "기타" || alarm.shiftType == todayShift)
if (shouldRingToday) {
scheduleAlarmForDate(context, alarm, today, team, factory)
Log.d(TAG, "오늘 알람 예약: ${alarm.time} (ID=${alarm.id})")
} else {
// 내일 알람 예약
val tomorrow = today.plusDays(1)
val tomorrowShift = repo.getShift(tomorrow, team, factory)
if (alarm.shiftType == "기타" || alarm.shiftType == tomorrowShift) {
scheduleAlarmForDate(context, alarm, tomorrow, team, factory)
Log.d(TAG, "내일 알람 예약: ${alarm.time} (ID=${alarm.id})")
} else {
// 근무 조건이 맞지 않으면 취소만 하고 예약 안함
cancelAlarmForDate(context, alarm.id, today)
cancelAlarmForDate(context, alarm.id, tomorrow)
}
}
}
// ============================================
// 특정 날짜 알람 예약
// ============================================
private suspend fun scheduleAlarmForDate(
context: Context,
alarm: CustomAlarm,
date: LocalDate,
team: String,
factory: String
) {
val alarmId = getAlarmId(alarm.id, date)
if (alarmId == -1) return
// 먼저 기존 알람 취소
cancelAlarmInternal(context, alarmId)
val parts = alarm.time.split(":")
val hour = parts[0].toIntOrNull() ?: return
val minute = parts[1].toIntOrNull() ?: return
val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, minute))
.withSecond(0).withNano(0) .withSecond(0).withNano(0)
val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli() val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli()
if (alarmTime > System.currentTimeMillis()) { // 과거 시간이면 예약 안함
setExactAlarm(context, alarmTime, pendingIntent) if (alarmTime <= System.currentTimeMillis()) {
Log.d(TAG, "알람 예약 완료: $date $time (ID: $alarmId)") return
} }
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER"
putExtra("EXTRA_ALARM_DB_ID", alarm.id)
putExtra("EXTRA_DATE", date.toString())
putExtra("EXTRA_TIME", alarm.time)
putExtra("EXTRA_SHIFT_TYPE", alarm.shiftType)
putExtra("EXTRA_SOUND", alarm.soundUri)
putExtra("EXTRA_SNOOZE", alarm.snoozeInterval)
putExtra("EXTRA_SNOOZE_REPEAT", alarm.snoozeRepeat)
}
val pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
setExactAlarm(context, alarmTime, pendingIntent)
Log.d(TAG, "알람 예약 완료: $date ${alarm.time} (AlarmID: $alarmId, DbID: ${alarm.id})")
} }
// ============================================ // ============================================
// 알람 취소 (전체 범위) // 알람 취소 - 특정 알람의 모든 예약 취소
// ============================================ // ============================================
fun cancelCustomAlarm(context: Context, date: LocalDate, uniqueId: Int) { fun cancelAlarm(context: Context, alarmId: Int) {
val alarmId = getCustomAlarmId(date, uniqueId) val today = LocalDate.now(SEOUL_ZONE)
// 오늘, 내일 알람 취소 (다른 날짜는 WorkManager가 매일 재설정)
cancelAlarmForDate(context, alarmId, today)
cancelAlarmForDate(context, alarmId, today.plusDays(1))
Log.d(TAG, "알람 취소 완료: ID=$alarmId")
}
// ============================================
// 특정 날짜 알람 취소
// ============================================
private fun cancelAlarmForDate(context: Context, alarmDbId: Int, date: LocalDate) {
val alarmId = getAlarmId(alarmDbId, date)
if (alarmId == -1) return
cancelAlarmInternal(context, alarmId) cancelAlarmInternal(context, alarmId)
} }
/** // ============================================
* 특정 알람의 모든 예약을 완전히 취소합니다. // 날짜에 대한 날짜 알람 취소
* DB에서 삭제하기 전에 반드시 호출해야 합니다. // ============================================
* 삭제한 알람이 울리는 문제를 해결하기 위해 365일치 + 과거 알람까지 모두 취소 suspend fun cancelAlarmsForDate(context: Context, date: LocalDate) {
*/ val repo = ShiftRepository(context)
fun cancelAllCustomAlarmSchedules(context: Context, uniqueId: Int) { val alarms = repo.getAllCustomAlarms()
val today = LocalDate.now(SEOUL_ZONE)
// 1. 과거 30일치 취소 (혹시 모를 과거 예약) for (alarm in alarms) {
for (i in -30 until 0) { cancelAlarmForDate(context, alarm.id, date)
val targetDate = today.plusDays(i.toLong())
cancelCustomAlarm(context, targetDate, uniqueId)
} }
// 2. 향후 365일치 모든 가능한 ID 취소 (1년치 완전 커버) Log.d(TAG, "${date} 날짜의 모든 알람 취소 완료")
for (i in 0 until 365) {
val targetDate = today.plusDays(i.toLong())
cancelCustomAlarm(context, targetDate, uniqueId)
}
// 3. 스누즈 알람도 취소 (스누즈는 999999 ID 사용)
cancelSnoozeAlarm(context)
// 4. 테스트 알람도 취소 (테스트는 888888 ID 사용)
cancelTestAlarm(context)
// 5. 해당 uniqueId와 관련된 모든 가능한 PendingIntent 취소 (추가 안전장치)
cancelAllPendingIntentsForUniqueId(context, uniqueId)
Log.d(TAG, "알람 예약 완전 취소 완료 (ID: $uniqueId, 범위: -30일 ~ +365일)")
} }
/** // ============================================
* 특정 uniqueId에 대한 모든 가능한 PendingIntent를 취소합니다. // 날짜에 대한 날짜 알람 스케줄링 (매일 자정 호출)
* 알람 ID 생성 공식의 역연산을 통해 모든 가능성을 커버합니다. // ============================================
*/ suspend fun scheduleAlarmsForDate(context: Context, date: LocalDate) {
private fun cancelAllPendingIntentsForUniqueId(context: Context, uniqueId: Int) { val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
Log.d(TAG, "마스터 알람 꺼짐, 스케줄링 중단")
return
}
val repo = ShiftRepository(context)
val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
// 해당 날짜의 근무 확인
val shift = repo.getShift(date, team, factory)
val alarms = repo.getAllCustomAlarms()
// 활성화된 알람 중 근무 조건이 맞는 것만 예약
for (alarm in alarms) {
if (!alarm.isEnabled) continue
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleAlarmForDate(context, alarm, date, team, factory)
} else {
// 조건 안 맞으면 취소
cancelAlarmForDate(context, alarm.id, date)
}
}
Log.d(TAG, "${date} 날짜 알람 스케줄링 완료")
}
// ============================================
// 모든 알람 취소 (마스터 오프 시)
// ============================================
suspend fun cancelAllAlarms(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val repo = ShiftRepository(context)
val alarms = repo.getAllCustomAlarms()
val today = LocalDate.now(SEOUL_ZONE)
// uniqueId % 100의 모든 가능한 값에 대해 취소 시도 for (alarm in alarms) {
val baseId = uniqueId % 100 // 오늘, 내일 알람 취소
for (dayOffset in 0..1) {
val date = today.plusDays(dayOffset.toLong())
val alarmId = getAlarmId(alarm.id, date)
if (alarmId == -1) continue
// 현재 연도 기준으로 여러 해에 걸친 가능한 ID들 val intent = Intent(context, AlarmReceiver::class.java).apply {
val currentYear = LocalDate.now(SEOUL_ZONE).year % 100 action = "com.example.shiftalarm.ALARM_TRIGGER"
val years = listOf(currentYear - 1, currentYear, currentYear + 1) }
val pendingIntent = PendingIntent.getBroadcast(
context, alarmId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
}
}
for (year in years) { // 스누즈 알람도 취소
if (year < 0) continue cancelSnoozeAlarm(context)
for (month in 1..12) {
for (day in 1..31) { Log.d(TAG, "모든 알람 취소 완료")
try { }
val alarmId = 200000000 + year * 1000000 + month * 10000 + day * 100 + baseId
val intent = Intent(context, AlarmReceiver::class.java).apply { // ============================================
action = "com.example.shiftalarm.ALARM_TRIGGER" // 날짜 알람 전체 동기화 (앱 시작/설정 변경 시)
} // ============================================
val pendingIntent = PendingIntent.getBroadcast( suspend fun syncAllAlarms(context: Context) {
context, alarmId, intent, Log.d(TAG, "===== 알람 동기화 시작 =====")
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel() if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
} catch (e: Exception) { Log.d(TAG, "마스터 알람 꺼짐, 모든 알람 취소")
// 무시 - 유효하지 않은 날짜 조합 cancelAllAlarms(context)
} return
}
val repo = ShiftRepository(context)
val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
val today = LocalDate.now(SEOUL_ZONE)
val alarms = repo.getAllCustomAlarms()
// 1. 모든 알람 취소 (깨끗한 상태에서 시작)
for (alarm in alarms) {
for (dayOffset in -1..1) { // 어제, 오늘, 내일
val date = today.plusDays(dayOffset.toLong())
cancelAlarmForDate(context, alarm.id, date)
}
}
// 2. 활성화된 알람 재예약
var scheduledCount = 0
for (alarm in alarms) {
if (!alarm.isEnabled) continue
// 오늘과 내일만 예약
for (dayOffset in 0..1) {
val date = today.plusDays(dayOffset.toLong())
val shift = repo.getShift(date, team, factory)
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleAlarmForDate(context, alarm, date, team, factory)
scheduledCount++
} }
} }
} }
Log.d(TAG, "uniqueId $uniqueId 관련 모든 PendingIntent 취소 완료") Log.d(TAG, "===== 알람 동기화 완료: $scheduledCount 개 예약 =====")
} }
/** // ============================================
* 스누즈 알람 취소 // 날짜 알람 취소 (날짜 날짜)
*/ // ============================================
/** internal fun cancelAlarmInternal(context: Context, alarmId: Int) {
* 스누즈 알람 취소 - 모든 가능한 스누즈 ID 취소
*/
fun cancelSnoozeAlarm(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
// 주요 스누즈 ID들 취소
val snoozeIds = listOf(999999, 999998, 999997, 999996, 999995)
for (snoozeId in snoozeIds) {
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.SNOOZE"
}
val pendingIntent = PendingIntent.getBroadcast(
context, snoozeId, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
}
Log.d(TAG, "스누즈 알람 취소 완료")
}
/**
* 테스트 알람 취소
*/
private fun cancelTestAlarm(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER"
}
val pendingIntent = PendingIntent.getBroadcast(
context, 888888, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
Log.d(TAG, "테스트 알람 취소 완료")
}
private fun cancelAlarmInternal(context: Context, alarmId: Int) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AlarmReceiver::class.java).apply { val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER" action = "com.example.shiftalarm.ALARM_TRIGGER"
@@ -214,9 +306,9 @@ private fun cancelAlarmInternal(context: Context, alarmId: Int) {
} }
// ============================================ // ============================================
// 정밀 알람 설정 (setAlarmClock 우선) // 정밀 알람 설정
// ============================================ // ============================================
private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: PendingIntent) { internal fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: PendingIntent) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -226,7 +318,6 @@ private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: Pe
} }
} }
// setAlarmClock은 Doze 모드에서도 정확하게 작동하며 상단바 알람 아이콘을 활성화함 (신뢰도 최고)
try { try {
val viewIntent = Intent(context, MainActivity::class.java).apply { val viewIntent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -238,16 +329,14 @@ private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: Pe
val clockInfo = AlarmManager.AlarmClockInfo(triggerTime, viewPendingIntent) val clockInfo = AlarmManager.AlarmClockInfo(triggerTime, viewPendingIntent)
alarmManager.setAlarmClock(clockInfo, pendingIntent) alarmManager.setAlarmClock(clockInfo, pendingIntent)
Log.d(TAG, "setAlarmClock 예약 성공: ${java.util.Date(triggerTime)}") Log.d(TAG, "setAlarmClock 예약 성공")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "setAlarmClock 실패, fallback 사용", e) Log.e(TAG, "setAlarmClock 실패, fallback 사용", e)
try { try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
Log.d(TAG, "setExactAndAllowWhileIdle 예약 성공")
} else { } else {
alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
Log.d(TAG, "setExact 예약 성공")
} }
} catch (e2: Exception) { } catch (e2: Exception) {
Log.e(TAG, "모든 알람 예약 방법 실패", e2) Log.e(TAG, "모든 알람 예약 방법 실패", e2)
@@ -256,7 +345,7 @@ private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: Pe
} }
// ============================================ // ============================================
// 스누즈 // 스누즈 알람
// ============================================ // ============================================
fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, snoozeRepeat: Int = 3) { fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, snoozeRepeat: Int = 3) {
val intent = Intent(context, AlarmReceiver::class.java).apply { val intent = Intent(context, AlarmReceiver::class.java).apply {
@@ -266,6 +355,7 @@ fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, s
putExtra("EXTRA_SNOOZE", snoozeMin) putExtra("EXTRA_SNOOZE", snoozeMin)
putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat) putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat)
} }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
context, 999999, intent, context, 999999, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
@@ -273,6 +363,27 @@ fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, s
val triggerTime = System.currentTimeMillis() + (snoozeMin * 60 * 1000) val triggerTime = System.currentTimeMillis() + (snoozeMin * 60 * 1000)
setExactAlarm(context, triggerTime, pendingIntent) setExactAlarm(context, triggerTime, pendingIntent)
Log.d(TAG, "스누즈 알람 예약: ${snoozeMin}분 후")
}
// ============================================
// 스누즈 알람 취소
// ============================================
fun cancelSnoozeAlarm(context: Context) {
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.SNOOZE"
}
val pendingIntent = PendingIntent.getBroadcast(
context, 999999, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
Log.d(TAG, "스누즈 알람 취소")
} }
// ============================================ // ============================================
@@ -281,74 +392,16 @@ fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, s
fun scheduleTestAlarm(context: Context) { fun scheduleTestAlarm(context: Context) {
val intent = Intent(context, AlarmReceiver::class.java).apply { val intent = Intent(context, AlarmReceiver::class.java).apply {
action = "com.example.shiftalarm.ALARM_TRIGGER" action = "com.example.shiftalarm.ALARM_TRIGGER"
putExtra("EXTRA_SHIFT", "테스트") putExtra("EXTRA_SHIFT_TYPE", "테스트")
putExtra("EXTRA_TIME", "TEST")
} }
val pendingIntent = PendingIntent.getBroadcast( val pendingIntent = PendingIntent.getBroadcast(
context, 888888, intent, context, 888888, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
val triggerTime = System.currentTimeMillis() + 5000 val triggerTime = System.currentTimeMillis() + 5000
setExactAlarm(context, triggerTime, pendingIntent) setExactAlarm(context, triggerTime, pendingIntent)
} Log.d(TAG, "테스트 알람 예약: 5초 후")
// ============================================
// 전체 동기화 (30일치 예약)
// ============================================
suspend fun syncAllAlarms(context: Context) {
Log.d(TAG, "===== 전체 알람 동기화 시작 (30일) =====")
val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE)
val repo = ShiftRepository(context)
val today = LocalDate.now(SEOUL_ZONE)
val team = prefs.getString("selected_team", "A") ?: "A"
val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju"
// 1. 기존 알람 모두 취소 (안전장치)
// Custom 알람의 경우 ID가 uniqueId 기반이므로 모든 가능성 있는 ID를 취소하기는 어려움.
// 대신 AlarmManager에서 해당 PendingIntent를 정확히 취소해야 함.
// 하지만 uniqueId를 알 수 없으므로, 모든 날짜 루프에서 취소 시도.
val customAlarms = repo.getAllCustomAlarms()
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
// 기본 알람 ID 취소 (이제 안 쓰지만 하위 호환/청소용)
val legacyId = 100000000 + (targetDate.year % 100) * 1000000 + targetDate.monthValue * 10000 + targetDate.dayOfMonth * 100
cancelAlarmInternal(context, legacyId)
// 커스텀 알람 취소
customAlarms.forEach { alarm ->
cancelCustomAlarm(context, targetDate, alarm.id)
}
}
if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) {
Log.d(TAG, "마스터 알람이 꺼져 있어 예약을 중단합니다.")
return
}
// 2. 새로운 스케줄 생성
for (i in 0 until 30) {
val targetDate = today.plusDays(i.toLong())
val shift = repo.getShift(targetDate, team, factory)
for (alarm in customAlarms) {
if (!alarm.isEnabled) continue
// 근무 연동 조건 확인
if (alarm.shiftType == "기타" || alarm.shiftType == shift) {
scheduleCustomAlarm(
context,
targetDate,
alarm.id,
alarm.shiftType,
alarm.time,
alarm.soundUri,
alarm.snoozeInterval,
alarm.snoozeRepeat
)
}
}
}
Log.d(TAG, "===== 전체 알람 동기화 완료 =====")
} }

View File

@@ -1,27 +1,32 @@
package com.example.shiftalarm package com.example.shiftalarm
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
/**
* 매일 자정 실행되는 알람 워커
* 다음날의 알람을 스케줄링합니다.
*/
class AlarmWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { class AlarmWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try { try {
syncAllAlarms(applicationContext) val tomorrow = LocalDate.now(SEOUL_ZONE).plusDays(1)
// 다음날 알람 스케줄링
AlarmSyncManager.scheduleAlarmsForDate(applicationContext, tomorrow)
// 오늘 남은 알람도 확인 (재부팅 등으로 누락됐을 수 있음)
val today = LocalDate.now(SEOUL_ZONE)
AlarmSyncManager.scheduleAlarmsForDate(applicationContext, today)
Result.success() Result.success()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() android.util.Log.e("AlarmWorker", "알람 스케줄링 실패", e)
Result.retry() Result.retry()
} }
} }

View File

@@ -9,12 +9,15 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* 부팅 완료 시 알람 복구 리시버
*/
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { if (intent?.action == Intent.ACTION_BOOT_COMPLETED) {
android.util.Log.d("ShiftAlarm", "[부팅] 기기 부팅 감지, 알람 복구 시작") android.util.Log.d("ShiftAlarm", "[부팅] 기기 부팅 감지, 알람 복구 시작")
// 1) 즉시 1회 실행 → 당일 알람을 바로 복구 // 즉시 1회 실행 → 당일 알람 복구
val immediateWork = OneTimeWorkRequestBuilder<AlarmWorker>().build() val immediateWork = OneTimeWorkRequestBuilder<AlarmWorker>().build()
WorkManager.getInstance(context).enqueueUniqueWork( WorkManager.getInstance(context).enqueueUniqueWork(
"BootAlarmRestore", "BootAlarmRestore",
@@ -22,16 +25,25 @@ class BootReceiver : BroadcastReceiver() {
immediateWork immediateWork
) )
// 2) 24시간 주기 반복 워커 등록 // 24시간 주기 반복 워커 등록 (자정에 실행)
val periodicWork = PeriodicWorkRequestBuilder<AlarmWorker>(24, TimeUnit.HOURS) val periodicWork = PeriodicWorkRequestBuilder<AlarmWorker>(24, TimeUnit.HOURS)
.setInitialDelay(calculateDelayToMidnight(), TimeUnit.MILLISECONDS)
.build() .build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork( WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"DailyShiftCheck", "DailyShiftCheck",
androidx.work.ExistingPeriodicWorkPolicy.KEEP, ExistingPeriodicWorkPolicy.KEEP,
periodicWork periodicWork
) )
android.util.Log.d("ShiftAlarm", "[부팅] 알람 복구 워커 등록 완료") android.util.Log.d("ShiftAlarm", "[부팅] 알람 복구 워커 등록 완료")
} }
} }
private fun calculateDelayToMidnight(): Long {
val seoulZone = java.time.ZoneId.of("Asia/Seoul")
val now = java.time.LocalDateTime.now(seoulZone)
val midnight = now.plusDays(1).withHour(0).withMinute(5).withSecond(0) // 00:05에 실행
return java.time.Duration.between(now, midnight).toMillis()
}
} }