From 380b2c5a6cec8081b500e2858c91a699d1b30771 Mon Sep 17 00:00:00 2001 From: sanjeok77 Date: Sat, 28 Feb 2026 18:10:46 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=95=8C=EB=9E=8C=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EB=8B=A8=EC=88=9C=ED=99=94=20-=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=95=8C=EB=9E=8C=20=EC=A0=9C=EA=B1=B0,?= =?UTF-8?q?=20ID=20=EC=B2=B4=EA=B3=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기본 알람(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 --- .../com/example/shiftalarm/AlarmReceiver.kt | 120 +++-- .../example/shiftalarm/AlarmSyncManager.kt | 287 +++++------ .../java/com/example/shiftalarm/AlarmUtils.kt | 483 ++++++++++-------- .../com/example/shiftalarm/AlarmWorker.kt | 23 +- .../com/example/shiftalarm/BootReceiver.kt | 18 +- 5 files changed, 502 insertions(+), 429 deletions(-) diff --git a/app/src/main/java/com/example/shiftalarm/AlarmReceiver.kt b/app/src/main/java/com/example/shiftalarm/AlarmReceiver.kt index b2312af..f5b0177 100644 --- a/app/src/main/java/com/example/shiftalarm/AlarmReceiver.kt +++ b/app/src/main/java/com/example/shiftalarm/AlarmReceiver.kt @@ -13,7 +13,7 @@ class AlarmReceiver : BroadcastReceiver() { private val TAG = "AlarmReceiver" override fun onReceive(context: Context, intent: Intent?) { - Log.d(TAG, "===== 알람 수신 (Receiver) =====") + Log.d(TAG, "===== 알람 수신 =====") // 마스터 알람이 꺼져있으면 알람 무시 val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) @@ -22,36 +22,69 @@ class AlarmReceiver : BroadcastReceiver() { return } - val alarmId = intent?.getIntExtra("EXTRA_ALARM_ID", -1) ?: -1 - val isCustom = intent?.getBooleanExtra("EXTRA_IS_CUSTOM", false) ?: false + val action = intent?.action - // 커스텀 알람인 경우 DB에서 여전히 유효한지 확인 (삭제된 알람이 울리는 문제 해결) - if (isCustom && alarmId != -1) { - val customAlarmId = intent.getIntExtra("EXTRA_UNIQUE_ID", -1) - if (customAlarmId != -1) { - // 비동기로 DB 확인 - val scope = CoroutineScope(Dispatchers.IO) - scope.launch { - val repo = ShiftRepository(context) - val alarms = repo.getAllCustomAlarms() - val alarmExists = alarms.any { it.id == customAlarmId && it.isEnabled } - - if (!alarmExists) { - Log.w(TAG, "삭제된 또는 비활성화된 알람입니다. 무시합니다. (ID: $customAlarmId)") - scope.cancel() - return@launch + when (action) { + "com.example.shiftalarm.SNOOZE" -> { + // 스누즈 알람 + startAlarm(context, intent) + } + else -> { + // 일반 알람 - DB에서 여전히 유효한지 확인 + val alarmDbId = intent?.getIntExtra("EXTRA_ALARM_DB_ID", -1) ?: -1 + + if (alarmDbId != -1) { + // 비동기로 DB 확인 + val scope = CoroutineScope(Dispatchers.IO) + 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() + } } - - // 알람이 유효하면 직접 AlarmActivity 실행 + Foreground Service 시작 + } else { + // DB ID가 없는 경우 (테스트 알람 등) - 바로 실행 startAlarm(context, intent) - scope.cancel() } - return } } - - // 일반 알람은 바로 직접 실행 - startAlarm(context, intent) } private fun startAlarm(context: Context, intent: Intent?) { @@ -62,15 +95,20 @@ class AlarmReceiver : BroadcastReceiver() { PowerManager.PARTIAL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP, "ShiftAlarm::AlarmWakeLock" ) - wakeLock.acquire(30 * 1000L) // 30초 - Activity 실행 및 초기화에 충분한 시간 + wakeLock.acquire(30 * 1000L) // 30초 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 { - putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무") - putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND")) - putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5) - putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3) + putExtra("EXTRA_SHIFT", shiftType) + putExtra("EXTRA_SOUND", soundUri) + putExtra("EXTRA_SNOOZE", snoozeMin) + putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -81,28 +119,24 @@ class AlarmReceiver : BroadcastReceiver() { Log.d(TAG, "ForegroundService 시작 완료") - // 2. AlarmActivity 직접 실행 (알람 화면 표시) + // 2. AlarmActivity 직접 실행 val activityIntent = Intent(context, AlarmActivity::class.java).apply { - putExtra("EXTRA_SHIFT", intent?.getStringExtra("EXTRA_SHIFT") ?: "근무") - putExtra("EXTRA_SOUND", intent?.getStringExtra("EXTRA_SOUND")) - putExtra("EXTRA_SNOOZE", intent?.getIntExtra("EXTRA_SNOOZE", 5) ?: 5) - putExtra("EXTRA_SNOOZE_REPEAT", intent?.getIntExtra("EXTRA_SNOOZE_REPEAT", 3) ?: 3) - // 중요: 새 태스크로 실행 (FLAG_ACTIVITY_NEW_TASK) + putExtra("EXTRA_SHIFT", shiftType) + putExtra("EXTRA_SOUND", soundUri) + putExtra("EXTRA_SNOOZE", snoozeMin) + putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - // 기존 인스턴스 재사용 및 최상위로 가져오기 addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) - // 잠금 화면 위에 표시 addFlags(Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT) - // 화면 켜기 addFlags(Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS) } - // 지연 후 Activity 시작 (ForegroundService가 알림을 먼저 표시하도록) + // 지연 후 Activity 시작 (ForegroundService가 먼저 시작되도록) android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ try { context.startActivity(activityIntent) - Log.d(TAG, "AlarmActivity 실행 완료") + Log.d(TAG, "AlarmActivity 실행 완료: $shiftType") } catch (e: Exception) { Log.e(TAG, "AlarmActivity 실행 실패", e) } @@ -111,7 +145,7 @@ class AlarmReceiver : BroadcastReceiver() { } catch (e: Exception) { Log.e(TAG, "알람 실행 실패", e) } finally { - // WakeLock은 Activity가 화면을 켜고 나서 해제 + // WakeLock 해제 android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ if (wakeLock.isHeld) wakeLock.release() }, 5000) diff --git a/app/src/main/java/com/example/shiftalarm/AlarmSyncManager.kt b/app/src/main/java/com/example/shiftalarm/AlarmSyncManager.kt index 7546b35..011e7fb 100644 --- a/app/src/main/java/com/example/shiftalarm/AlarmSyncManager.kt +++ b/app/src/main/java/com/example/shiftalarm/AlarmSyncManager.kt @@ -4,25 +4,22 @@ import android.content.Context import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import java.time.LocalDate /** - * 알람 동기화 관리자 - * DB와 AlarmManager 간의 실시간 동기화를 보장합니다. + * 단순화된 알람 동기화 관리자 * - * 동기화 전략: - * 1. DB 작업과 AlarmManager 작업을 원자적으로 처리 - * 2. 실패 시 롤백 메커니즘 제공 - * 3. 동기화 상태 추적 및 재시도 + * 핵심 원칙: + * 1. 알람 추가/삭제/수정 시 즉시 AlarmManager에 반영 + * 2. 복잡한 예약 취소 로직 제거 - 단순한 ID 체계 사용 + * 3. 매일 자정 WorkManager가 다음날 알람 스케줄링 */ object AlarmSyncManager { private const val TAG = "AlarmSyncManager" - private const val PREFS_NAME = "AlarmSyncPrefs" /** - * 알람 추가 동기화 - * DB에 추가 후 AlarmManager에 즉시 예약 + * 알람 추가 + * DB에 추가 후 즉시 AlarmManager에 예약 */ suspend fun addAlarm(context: Context, alarm: CustomAlarm): Result = withContext(Dispatchers.IO) { try { @@ -32,64 +29,32 @@ object AlarmSyncManager { val alarmId = repo.addCustomAlarm(alarm) Log.d(TAG, "알람 DB 추가 완료: ID=$alarmId") - // 2. AlarmManager에 예약 - val today = LocalDate.now(SEOUL_ZONE) - val customAlarms = repo.getAllCustomAlarms() - val addedAlarm = customAlarms.find { it.id == alarmId.toInt() } + // 2. AlarmManager에 즉시 예약 + val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) + val team = prefs.getString("selected_team", "A") ?: "A" + val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" - if (addedAlarm == null) { - Log.w(TAG, "추가된 알람을 DB에서 찾을 수 없음: ID=$alarmId") - 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()) + val addedAlarm = alarm.copy(id = alarmId.toInt()) + scheduleNextAlarm(context, addedAlarm, team, factory) Result.success(Unit) } catch (e: Exception) { - Log.e(TAG, "알람 추가 동기화 실패", e) + Log.e(TAG, "알람 추가 실패", e) Result.failure(e) } } /** - * 알람 수정 동기화 - * DB 수정 후 기존 AlarmManager 예약 취소 후 재예약 + * 알람 수정 + * DB 수정 후 기존 예약 취소 후 재예약 */ suspend fun updateAlarm(context: Context, alarm: CustomAlarm): Result = withContext(Dispatchers.IO) { try { val repo = ShiftRepository(context) - // 1. 기존 AlarmManager 예약 취소 - cancelAllCustomAlarmSchedules(context, alarm.id) - Log.d(TAG, "기존 알람 예약 취소 완료: ID=${alarm.id}") + // 1. 기존 예약 취소 + cancelAlarm(context, alarm.id) + Log.d(TAG, "기존 알람 취소 완료: ID=${alarm.id}") // 2. DB 업데이트 repo.updateCustomAlarm(alarm) @@ -97,70 +62,45 @@ object AlarmSyncManager { // 3. 활성화된 알람이면 재예약 if (alarm.isEnabled) { - 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) { - scheduleCustomAlarm( - context, - targetDate, - alarm.id, - alarm.shiftType, - alarm.time, - alarm.soundUri, - alarm.snoozeInterval, - alarm.snoozeRepeat - ) - } - } + val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) + val team = prefs.getString("selected_team", "A") ?: "A" + val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" + + scheduleNextAlarm(context, alarm, team, factory) Log.d(TAG, "알람 재예약 완료: ID=${alarm.id}") } - // 4. 동기화 상태 저장 - saveSyncStatus(context, "last_update_alarm", System.currentTimeMillis()) - Result.success(Unit) } catch (e: Exception) { - Log.e(TAG, "알람 수정 동기화 실패", e) + Log.e(TAG, "알람 수정 실패", e) Result.failure(e) } } /** - * 알람 삭제 동기화 + * 알람 삭제 * AlarmManager 예약 먼저 취소 후 DB에서 삭제 */ suspend fun deleteAlarm(context: Context, alarm: CustomAlarm): Result = withContext(Dispatchers.IO) { try { - val repo = ShiftRepository(context) - // 1. AlarmManager 예약 취소 (DB 삭제 전에 먼저!) - cancelAllCustomAlarmSchedules(context, alarm.id) + cancelAlarm(context, alarm.id) Log.d(TAG, "알람 예약 취소 완료: ID=${alarm.id}") // 2. DB에서 삭제 + val repo = ShiftRepository(context) repo.deleteCustomAlarm(alarm) Log.d(TAG, "알람 DB 삭제 완료: ID=${alarm.id}") - // 3. 동기화 상태 저장 - saveSyncStatus(context, "last_delete_alarm", System.currentTimeMillis()) - Result.success(Unit) } catch (e: Exception) { - Log.e(TAG, "알람 삭제 동기화 실패", e) + Log.e(TAG, "알람 삭제 실패", e) Result.failure(e) } } /** - * 알람 토글 동기화 (활성화/비활성화) + * 알람 토글 (활성화/비활성화) */ suspend fun toggleAlarm(context: Context, alarm: CustomAlarm, enable: Boolean): Result = withContext(Dispatchers.IO) { try { @@ -170,94 +110,67 @@ object AlarmSyncManager { if (enable) { // 활성화: DB 업데이트 후 예약 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) { - scheduleCustomAlarm( - context, - targetDate, - alarm.id, - alarm.shiftType, - alarm.time, - alarm.soundUri, - alarm.snoozeInterval, - alarm.snoozeRepeat - ) - } - } + + val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) + val team = prefs.getString("selected_team", "A") ?: "A" + val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" + + scheduleNextAlarm(context, updatedAlarm, team, factory) Log.d(TAG, "알람 활성화 완료: ID=${alarm.id}") } else { // 비활성화: 예약 취소 후 DB 업데이트 - cancelAllCustomAlarmSchedules(context, alarm.id) + cancelAlarm(context, alarm.id) repo.updateCustomAlarm(updatedAlarm) Log.d(TAG, "알람 비활성화 완료: ID=${alarm.id}") } - saveSyncStatus(context, "last_toggle_alarm", System.currentTimeMillis()) Result.success(Unit) } catch (e: Exception) { - Log.e(TAG, "알람 토글 동기화 실패", e) + Log.e(TAG, "알람 토글 실패", e) Result.failure(e) } } /** - * 전체 알람 동기화 (앱 시작 시 호출) + * 전체 알람 동기화 (앱 시작/설정 변경 시) + * 깨끗한 상태에서 모든 활성화된 알람 재예약 */ suspend fun syncAllAlarmsWithCheck(context: Context): Result = withContext(Dispatchers.IO) { try { Log.d(TAG, "전체 알람 동기화 시작") - // 1. 기존 모든 알람 취소 + val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) val repo = ShiftRepository(context) val allAlarms = repo.getAllCustomAlarms() + // 1. 모든 기존 알람 취소 for (alarm in allAlarms) { - cancelAllCustomAlarmSchedules(context, alarm.id) + cancelAlarm(context, alarm.id) } Log.d(TAG, "기존 모든 알람 취소 완료: ${allAlarms.size}개") - // 2. 활성화된 알람만 재예약 - val enabledAlarms = allAlarms.filter { it.isEnabled } - val today = LocalDate.now(SEOUL_ZONE) - val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) + // 2. 마스터 알람이 꺼져있으면 여기서 종료 + if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { + Log.d(TAG, "마스터 알람 꺼짐, 동기화 종료") + return@withContext Result.success(SyncResult( + totalAlarms = allAlarms.size, + enabledAlarms = 0, + scheduledAlarms = 0 + )) + } + + // 3. 활성화된 알람만 재예약 val team = prefs.getString("selected_team", "A") ?: "A" val factory = prefs.getString("selected_factory", "Jeonju") ?: "Jeonju" + val enabledAlarms = allAlarms.filter { it.isEnabled } var scheduledCount = 0 for (alarm in enabledAlarms) { - for (i in 0 until 30) { - val targetDate = today.plusDays(i.toLong()) - 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++ - } - } + scheduleNextAlarm(context, alarm, team, factory) + scheduledCount++ } - Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람, ${scheduledCount}개 예약") - - // 3. 동기화 상태 저장 - saveSyncStatus(context, "last_full_sync", System.currentTimeMillis()) + Log.d(TAG, "알람 재예약 완료: ${enabledAlarms.size}개 알람") Result.success(SyncResult( totalAlarms = allAlarms.size, @@ -271,21 +184,77 @@ object AlarmSyncManager { } /** - * 동기화 상태 저장 + * 특정 날짜 알람 스케줄링 (WorkManager에서 매일 자정 호출) */ - private fun saveSyncStatus(context: Context, key: String, timestamp: Long) { - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .putLong(key, timestamp) - .apply() - } - - /** - * 마지막 동기화 시간 확인 - */ - fun getLastSyncTime(context: Context, key: String): Long { - return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .getLong(key, 0) + suspend fun scheduleAlarmsForDate(context: Context, date: java.time.LocalDate): Result = withContext(Dispatchers.IO) { + try { + val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) + + if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { + return@withContext Result.success(Unit) + } + + val repo = ShiftRepository(context) + 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) + } } /** diff --git a/app/src/main/java/com/example/shiftalarm/AlarmUtils.kt b/app/src/main/java/com/example/shiftalarm/AlarmUtils.kt index ee4c71d..ff702b3 100644 --- a/app/src/main/java/com/example/shiftalarm/AlarmUtils.kt +++ b/app/src/main/java/com/example/shiftalarm/AlarmUtils.kt @@ -6,201 +6,293 @@ import android.content.Context import android.content.Intent import android.os.Build import android.util.Log -import android.widget.Toast import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.ZoneId -import java.util.concurrent.TimeUnit val SEOUL_ZONE: ZoneId = ZoneId.of("Asia/Seoul") const val TAG = "ShiftAlarm" // ============================================ -// 알람 ID 생성 +// 알람 ID 생성 - 단순화: alarmId * 10000 + 날짜 기반 오프셋 // ============================================ -fun getCustomAlarmId(date: LocalDate, uniqueId: Int): Int { - // Combine date and a unique ID from DB to avoid collisions - // Using (uniqueId % 1000) to keep it within a reasonable range - return 200000000 + (date.year % 100) * 1000000 + date.monthValue * 10000 + date.dayOfMonth * 100 + (uniqueId % 100) +fun getAlarmId(alarmDbId: Int, date: LocalDate): Int { + // alarmDbId: 1, 2, 3... + // 날짜 오프셋: 오늘=0, 내일=1, ... 6일 후=6 (일주일 단위로 재사용) + 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( - context: Context, - date: LocalDate, - uniqueId: Int, - shiftType: String, - time: String, - soundUri: String? = null, - snoozeMin: Int = 5, - snoozeRepeat: Int = 3 +suspend fun scheduleNextAlarm( + context: Context, + alarm: CustomAlarm, + team: String, + factory: String ) { - val alarmId = getCustomAlarmId(date, uniqueId) - val label = "사용자:$shiftType" + if (!alarm.isEnabled) { + Log.d(TAG, "알람 비활성화됨, 예약 안함: ID=${alarm.id}") + return + } - val parts = time.split(":") + val repo = ShiftRepository(context) + val today = LocalDate.now(SEOUL_ZONE) + val now = LocalDateTime.now(SEOUL_ZONE) + + // 시간 파싱 + val parts = alarm.time.split(":") if (parts.size != 2) return val hour = parts[0].toIntOrNull() ?: return - val min = parts[1].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) + + val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli() + + // 과거 시간이면 예약 안함 + if (alarmTime <= System.currentTimeMillis()) { + return + } + val intent = Intent(context, AlarmReceiver::class.java).apply { action = "com.example.shiftalarm.ALARM_TRIGGER" - putExtra("EXTRA_SHIFT", label) + putExtra("EXTRA_ALARM_DB_ID", alarm.id) 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) + 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 ) - - val targetDateTime = LocalDateTime.of(date, LocalTime.of(hour, min)) - .withSecond(0).withNano(0) - - val alarmTime = targetDateTime.atZone(SEOUL_ZONE).toInstant().toEpochMilli() - - if (alarmTime > System.currentTimeMillis()) { - setExactAlarm(context, alarmTime, pendingIntent) - Log.d(TAG, "알람 예약 완료: $date $time (ID: $alarmId)") - } + + setExactAlarm(context, alarmTime, pendingIntent) + Log.d(TAG, "알람 예약 완료: $date ${alarm.time} (AlarmID: $alarmId, DbID: ${alarm.id})") } // ============================================ -// 알람 취소 (전체 범위) +// 알람 취소 - 특정 알람의 모든 예약 취소 // ============================================ -fun cancelCustomAlarm(context: Context, date: LocalDate, uniqueId: Int) { - val alarmId = getCustomAlarmId(date, uniqueId) +fun cancelAlarm(context: Context, alarmId: Int) { + 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) } -/** - * 특정 알람의 모든 예약을 완전히 취소합니다. - * DB에서 삭제하기 전에 반드시 호출해야 합니다. - * 삭제한 알람이 울리는 문제를 해결하기 위해 365일치 + 과거 알람까지 모두 취소 - */ -fun cancelAllCustomAlarmSchedules(context: Context, uniqueId: Int) { - val today = LocalDate.now(SEOUL_ZONE) +// ============================================ +// 날짜에 대한 날짜 알람 취소 +// ============================================ +suspend fun cancelAlarmsForDate(context: Context, date: LocalDate) { + val repo = ShiftRepository(context) + val alarms = repo.getAllCustomAlarms() - // 1. 과거 30일치 취소 (혹시 모를 과거 예약) - for (i in -30 until 0) { - val targetDate = today.plusDays(i.toLong()) - cancelCustomAlarm(context, targetDate, uniqueId) + for (alarm in alarms) { + cancelAlarmForDate(context, alarm.id, date) } - // 2. 향후 365일치 모든 가능한 ID 취소 (1년치 완전 커버) - 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일)") + Log.d(TAG, "${date} 날짜의 모든 알람 취소 완료") } -/** - * 특정 uniqueId에 대한 모든 가능한 PendingIntent를 취소합니다. - * 알람 ID 생성 공식의 역연산을 통해 모든 가능성을 커버합니다. - */ -private fun cancelAllPendingIntentsForUniqueId(context: Context, uniqueId: Int) { +// ============================================ +// 날짜에 대한 날짜 알람 스케줄링 (매일 자정 호출) +// ============================================ +suspend fun scheduleAlarmsForDate(context: Context, date: LocalDate) { + 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 repo = ShiftRepository(context) + val alarms = repo.getAllCustomAlarms() + val today = LocalDate.now(SEOUL_ZONE) - // uniqueId % 100의 모든 가능한 값에 대해 취소 시도 - val baseId = uniqueId % 100 + for (alarm in alarms) { + // 오늘, 내일 알람 취소 + for (dayOffset in 0..1) { + val date = today.plusDays(dayOffset.toLong()) + val alarmId = getAlarmId(alarm.id, date) + if (alarmId == -1) continue + + val intent = Intent(context, AlarmReceiver::class.java).apply { + action = "com.example.shiftalarm.ALARM_TRIGGER" + } + val pendingIntent = PendingIntent.getBroadcast( + context, alarmId, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager.cancel(pendingIntent) + pendingIntent.cancel() + } + } - // 현재 연도 기준으로 여러 해에 걸친 가능한 ID들 - val currentYear = LocalDate.now(SEOUL_ZONE).year % 100 - val years = listOf(currentYear - 1, currentYear, currentYear + 1) + // 스누즈 알람도 취소 + cancelSnoozeAlarm(context) - for (year in years) { - if (year < 0) continue - for (month in 1..12) { - for (day in 1..31) { - 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( - context, alarmId, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - alarmManager.cancel(pendingIntent) - pendingIntent.cancel() - } catch (e: Exception) { - // 무시 - 유효하지 않은 날짜 조합 - } + Log.d(TAG, "모든 알람 취소 완료") +} + +// ============================================ +// 날짜 알람 전체 동기화 (앱 시작/설정 변경 시) +// ============================================ +suspend fun syncAllAlarms(context: Context) { + Log.d(TAG, "===== 알람 동기화 시작 =====") + + val prefs = context.getSharedPreferences("ShiftAlarmPrefs", Context.MODE_PRIVATE) + + if (!ShiftAlarmDefaults.isMasterAlarmEnabled(prefs)) { + 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 개 예약 =====") } -/** - * 스누즈 알람 취소 - */ -/** - * 스누즈 알람 취소 - 모든 가능한 스누즈 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) { +// ============================================ +// 날짜 알람 취소 (날짜 날짜) +// ============================================ +internal fun cancelAlarmInternal(context: Context, alarmId: Int) { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager val intent = Intent(context, AlarmReceiver::class.java).apply { 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 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -225,8 +317,7 @@ private fun setExactAlarm(context: Context, triggerTime: Long, pendingIntent: Pe return } } - - // setAlarmClock은 Doze 모드에서도 정확하게 작동하며 상단바 알람 아이콘을 활성화함 (신뢰도 최고) + try { val viewIntent = Intent(context, MainActivity::class.java).apply { 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) alarmManager.setAlarmClock(clockInfo, pendingIntent) - Log.d(TAG, "setAlarmClock 예약 성공: ${java.util.Date(triggerTime)}") + Log.d(TAG, "setAlarmClock 예약 성공") } catch (e: Exception) { Log.e(TAG, "setAlarmClock 실패, fallback 사용", e) try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) - Log.d(TAG, "setExactAndAllowWhileIdle 예약 성공") } else { alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) - Log.d(TAG, "setExact 예약 성공") } } catch (e2: Exception) { 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) { val intent = Intent(context, AlarmReceiver::class.java).apply { @@ -266,13 +355,35 @@ fun scheduleSnooze(context: Context, snoozeMin: Int, soundUri: String? = null, s putExtra("EXTRA_SNOOZE", snoozeMin) putExtra("EXTRA_SNOOZE_REPEAT", snoozeRepeat) } + val pendingIntent = PendingIntent.getBroadcast( context, 999999, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - + val triggerTime = System.currentTimeMillis() + (snoozeMin * 60 * 1000) 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) { val intent = Intent(context, AlarmReceiver::class.java).apply { action = "com.example.shiftalarm.ALARM_TRIGGER" - putExtra("EXTRA_SHIFT", "테스트") + putExtra("EXTRA_SHIFT_TYPE", "테스트") + putExtra("EXTRA_TIME", "TEST") } + val pendingIntent = PendingIntent.getBroadcast( context, 888888, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val triggerTime = System.currentTimeMillis() + 5000 setExactAlarm(context, triggerTime, pendingIntent) -} - -// ============================================ -// 전체 동기화 (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, "===== 전체 알람 동기화 완료 =====") + Log.d(TAG, "테스트 알람 예약: 5초 후") } diff --git a/app/src/main/java/com/example/shiftalarm/AlarmWorker.kt b/app/src/main/java/com/example/shiftalarm/AlarmWorker.kt index 895b22d..96a6685 100644 --- a/app/src/main/java/com/example/shiftalarm/AlarmWorker.kt +++ b/app/src/main/java/com/example/shiftalarm/AlarmWorker.kt @@ -1,27 +1,32 @@ package com.example.shiftalarm -import android.app.AlarmManager -import android.app.PendingIntent import android.content.Context -import android.content.Intent import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext 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) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) { 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() } catch (e: Exception) { - e.printStackTrace() + android.util.Log.e("AlarmWorker", "알람 스케줄링 실패", e) Result.retry() } } diff --git a/app/src/main/java/com/example/shiftalarm/BootReceiver.kt b/app/src/main/java/com/example/shiftalarm/BootReceiver.kt index 78c737b..0f211eb 100644 --- a/app/src/main/java/com/example/shiftalarm/BootReceiver.kt +++ b/app/src/main/java/com/example/shiftalarm/BootReceiver.kt @@ -9,12 +9,15 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import java.util.concurrent.TimeUnit +/** + * 부팅 완료 시 알람 복구 리시버 + */ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { if (intent?.action == Intent.ACTION_BOOT_COMPLETED) { android.util.Log.d("ShiftAlarm", "[부팅] 기기 부팅 감지, 알람 복구 시작") - // 1) 즉시 1회 실행 → 당일 알람을 바로 복구 + // 즉시 1회 실행 → 당일 알람 복구 val immediateWork = OneTimeWorkRequestBuilder().build() WorkManager.getInstance(context).enqueueUniqueWork( "BootAlarmRestore", @@ -22,16 +25,25 @@ class BootReceiver : BroadcastReceiver() { immediateWork ) - // 2) 24시간 주기 반복 워커 등록 + // 24시간 주기 반복 워커 등록 (자정에 실행) val periodicWork = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) + .setInitialDelay(calculateDelayToMidnight(), TimeUnit.MILLISECONDS) .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( "DailyShiftCheck", - androidx.work.ExistingPeriodicWorkPolicy.KEEP, + ExistingPeriodicWorkPolicy.KEEP, periodicWork ) 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() + } }