diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 905e3c79..325ea717 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -74,7 +74,7 @@ android:windowSoftInputMode="adjustPan" /> + + + + - + - + - + if (countDownTimers[timer.id] == null) { - EventBus.getDefault().post(TimerEvent.Start(timer.id!!, (timer.state as TimerState.Running).tick)) + EventBus.getDefault().post( + TimerEvent.Start( + timerId = timer.id!!, + duration = (timer.state as TimerState.Running).tick + ) + ) } } } @@ -108,8 +116,7 @@ class App : Application(), LifecycleObserver { fun onMessageEvent(event: TimerEvent.Finish) { timerHelper.getTimer(event.timerId) { timer -> val pendingIntent = getOpenTimerTabIntent(event.timerId) - val notification = getTimerNotification(timer, pendingIntent, false) - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notification = getTimerNotification(timer, pendingIntent) try { notificationManager.notify(event.timerId, notification) @@ -127,7 +134,10 @@ class App : Application(), LifecycleObserver { @Subscribe(threadMode = ThreadMode.MAIN) fun onMessageEvent(event: TimerEvent.Pause) { timerHelper.getTimer(event.timerId) { timer -> - updateTimerState(event.timerId, TimerState.Paused(event.duration, (timer.state as TimerState.Running).tick)) + updateTimerState( + event.timerId, + TimerState.Paused(event.duration, (timer.state as TimerState.Running).tick) + ) countDownTimers[event.timerId]?.cancel() } } diff --git a/app/src/main/kotlin/org/fossify/clock/activities/AlarmActivity.kt b/app/src/main/kotlin/org/fossify/clock/activities/AlarmActivity.kt new file mode 100644 index 00000000..0bf4a4ac --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/activities/AlarmActivity.kt @@ -0,0 +1,236 @@ +package org.fossify.clock.activities + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.AlarmClock +import android.view.MotionEvent +import android.view.WindowManager +import android.view.animation.AnimationUtils +import org.fossify.clock.R +import org.fossify.clock.databinding.ActivityAlarmBinding +import org.fossify.clock.extensions.alarmController +import org.fossify.clock.extensions.config +import org.fossify.clock.extensions.dbHelper +import org.fossify.clock.extensions.getFormattedTime +import org.fossify.clock.helpers.ALARM_ID +import org.fossify.clock.helpers.getPassedSeconds +import org.fossify.clock.models.Alarm +import org.fossify.clock.models.AlarmEvent +import org.fossify.commons.extensions.applyColorFilter +import org.fossify.commons.extensions.getProperBackgroundColor +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.onGlobalLayout +import org.fossify.commons.extensions.performHapticFeedback +import org.fossify.commons.extensions.showPickSecondsDialog +import org.fossify.commons.extensions.updateTextColors +import org.fossify.commons.extensions.viewBinding +import org.fossify.commons.helpers.MINUTE_SECONDS +import org.fossify.commons.helpers.isOreoMr1Plus +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import kotlin.math.max +import kotlin.math.min + +class AlarmActivity : SimpleActivity() { + + private val swipeGuideFadeHandler = Handler(Looper.getMainLooper()) + private var alarm: Alarm? = null + private var didVibrate = false + private var dragDownX = 0f + + private val binding by viewBinding(ActivityAlarmBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + isMaterialActivity = true + super.onCreate(savedInstanceState) + setContentView(binding.root) + showOverLockscreen() + updateTextColors(binding.root) + updateStatusbarColor(getProperBackgroundColor()) + + val id = intent.getIntExtra(ALARM_ID, -1) + alarm = dbHelper.getAlarmWithId(id) + if (alarm == null) { + finish() + return + } + + val label = alarm!!.label.ifEmpty { + getString(org.fossify.commons.R.string.alarm) + } + + binding.reminderTitle.text = label + binding.reminderText.text = getFormattedTime( + passedSeconds = getPassedSeconds(), + showSeconds = false, + makeAmPmSmaller = false + ) + + setupAlarmButtons() + EventBus.getDefault().register(this) + } + + @SuppressLint("ClickableViewAccessibility") + private fun setupAlarmButtons() { + binding.reminderDraggableBackground.startAnimation( + AnimationUtils.loadAnimation(this, R.anim.pulsing_animation) + ) + binding.reminderDraggableBackground.applyColorFilter(getProperPrimaryColor()) + + val textColor = getProperTextColor() + binding.reminderDismiss.applyColorFilter(textColor) + binding.reminderDraggable.applyColorFilter(textColor) + binding.reminderSnooze.applyColorFilter(textColor) + + var minDragX = 0f + var maxDragX = 0f + var initialDraggableX = 0f + + binding.reminderDismiss.onGlobalLayout { + minDragX = binding.reminderSnooze.left.toFloat() + maxDragX = binding.reminderDismiss.left.toFloat() + initialDraggableX = binding.reminderDraggable.left.toFloat() + } + + binding.reminderDraggable.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + dragDownX = event.x + binding.reminderDraggableBackground.animate().alpha(0f) + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + dragDownX = 0f + if (!didVibrate) { + binding.reminderDraggable.animate().x(initialDraggableX).withEndAction { + binding.reminderDraggableBackground.animate().alpha(0.2f) + } + + binding.reminderGuide.animate().alpha(1f).start() + swipeGuideFadeHandler.removeCallbacksAndMessages(null) + swipeGuideFadeHandler.postDelayed({ + binding.reminderGuide.animate().alpha(0f).start() + }, 2000L) + } + } + + MotionEvent.ACTION_MOVE -> { + binding.reminderDraggable.x = min( + a = maxDragX, + b = max(minDragX, event.rawX - dragDownX) + ) + + if (binding.reminderDraggable.x >= maxDragX - 50f) { + if (!didVibrate) { + binding.reminderDraggable.performHapticFeedback() + didVibrate = true + dismissAlarmAndFinish() + } + } else if (binding.reminderDraggable.x <= minDragX + 50f) { + if (!didVibrate) { + binding.reminderDraggable.performHapticFeedback() + didVibrate = true + snoozeAlarm() + } + } + } + } + true + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + setupAlarmButtons() + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + when (intent?.action) { + AlarmClock.ACTION_DISMISS_ALARM -> dismissAlarmAndFinish() + AlarmClock.ACTION_SNOOZE_ALARM -> { + val durationMinutes = intent.getIntExtra(AlarmClock.EXTRA_ALARM_SNOOZE_DURATION, -1) + if (durationMinutes == -1) { + snoozeAlarm() + } else { + snoozeAlarm(durationMinutes) + } + } + + else -> { + // no-op. user probably clicked the notification + } + } + } + + override fun onDestroy() { + super.onDestroy() + swipeGuideFadeHandler.removeCallbacksAndMessages(null) + EventBus.getDefault().unregister(this) + } + + private fun snoozeAlarm(overrideSnoozeDuration: Int? = null) { + if (overrideSnoozeDuration != null) { + dismissAlarmAndFinish(overrideSnoozeDuration) + } else if (config.useSameSnooze) { + dismissAlarmAndFinish(config.snoozeTime) + } else { + alarmController.stopAlarm(alarmId = alarm!!.id, disable = false) + showPickSecondsDialog( + curSeconds = config.snoozeTime * MINUTE_SECONDS, + isSnoozePicker = true, + cancelCallback = { + dismissAlarmAndFinish() + }, + callback = { + config.snoozeTime = it / MINUTE_SECONDS + dismissAlarmAndFinish(config.snoozeTime) + } + ) + } + } + + private fun dismissAlarmAndFinish(snoozeMinutes: Int = -1) { + if (alarm != null) { + if (snoozeMinutes != -1) { + alarmController.snoozeAlarm(alarm!!.id, snoozeMinutes) + } else { + alarmController.stopAlarm(alarm!!.id) + } + } + + finishActivity() + } + + private fun showOverLockscreen() { + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + + if (isOreoMr1Plus()) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onAlarmStoppedEvent(event: AlarmEvent.Stopped) { + if (event.alarmId == alarm?.id && !isFinishing) { + finishActivity() + } + } + + private fun finishActivity() { + finish() + overridePendingTransition(0, 0) + } +} diff --git a/app/src/main/kotlin/org/fossify/clock/activities/IntentHandlerActivity.kt b/app/src/main/kotlin/org/fossify/clock/activities/IntentHandlerActivity.kt index b8bbb8a8..a3a42c77 100644 --- a/app/src/main/kotlin/org/fossify/clock/activities/IntentHandlerActivity.kt +++ b/app/src/main/kotlin/org/fossify/clock/activities/IntentHandlerActivity.kt @@ -1,20 +1,39 @@ package org.fossify.clock.activities import android.annotation.SuppressLint -import android.app.AlarmManager -import android.content.Context import android.content.Intent import android.media.RingtoneManager -import android.net.Uri import android.os.Bundle import android.provider.AlarmClock +import androidx.core.net.toUri import org.fossify.clock.R import org.fossify.clock.dialogs.EditAlarmDialog import org.fossify.clock.dialogs.EditTimerDialog import org.fossify.clock.dialogs.SelectAlarmDialog -import org.fossify.clock.extensions.* -import org.fossify.clock.helpers.* -import org.fossify.clock.models.* +import org.fossify.clock.extensions.alarmController +import org.fossify.clock.extensions.alarmManager +import org.fossify.clock.extensions.config +import org.fossify.clock.extensions.createNewAlarm +import org.fossify.clock.extensions.createNewTimer +import org.fossify.clock.extensions.dbHelper +import org.fossify.clock.extensions.getHideTimerPendingIntent +import org.fossify.clock.extensions.getSkipUpcomingAlarmPendingIntent +import org.fossify.clock.extensions.isBitSet +import org.fossify.clock.extensions.secondsToMillis +import org.fossify.clock.extensions.timerHelper +import org.fossify.clock.helpers.DEFAULT_ALARM_MINUTES +import org.fossify.clock.helpers.TODAY_BIT +import org.fossify.clock.helpers.TOMORROW_BIT +import org.fossify.clock.helpers.UPCOMING_ALARM_NOTIFICATION_ID +import org.fossify.clock.helpers.getBitForCalendarDay +import org.fossify.clock.helpers.getCurrentDayMinutes +import org.fossify.clock.helpers.getTodayBit +import org.fossify.clock.helpers.getTomorrowBit +import org.fossify.clock.models.Alarm +import org.fossify.clock.models.AlarmEvent +import org.fossify.clock.models.Timer +import org.fossify.clock.models.TimerEvent +import org.fossify.clock.models.TimerState import org.fossify.commons.dialogs.PermissionRequiredDialog import org.fossify.commons.extensions.getDefaultAlarmSound import org.fossify.commons.extensions.getFilenameFromUri @@ -65,7 +84,8 @@ class IntentHandlerActivity : SimpleActivity() { private fun Intent.setNewAlarm() { val hour = getIntExtra(AlarmClock.EXTRA_HOUR, 0).coerceIn(0, 23) val minute = getIntExtra(AlarmClock.EXTRA_MINUTES, 0).coerceIn(0, 59) - val days = getIntegerArrayListExtra(AlarmClock.EXTRA_DAYS) ?: getIntArrayExtra(AlarmClock.EXTRA_DAYS)?.toList() + val days = getIntegerArrayListExtra(AlarmClock.EXTRA_DAYS) + ?: getIntArrayExtra(AlarmClock.EXTRA_DAYS)?.toList() val message = getStringExtra(AlarmClock.EXTRA_MESSAGE) val ringtone = getStringExtra(AlarmClock.EXTRA_RINGTONE) val vibrate = getBooleanExtra(AlarmClock.EXTRA_VIBRATE, true) @@ -81,7 +101,7 @@ class IntentHandlerActivity : SimpleActivity() { AlarmSound(0, getString(org.fossify.commons.R.string.no_sound), SILENT) } else { try { - val uri = Uri.parse(it) + val uri = it.toUri() var filename = getFilenameFromUri(uri) if (filename.isEmpty()) { filename = getString(org.fossify.commons.R.string.alarm) @@ -106,11 +126,11 @@ class IntentHandlerActivity : SimpleActivity() { } val existingAlarm = dbHelper.getAlarms().firstOrNull { it.days == daysToCompare - && it.vibrate == vibrate - && it.soundTitle == soundToUse.title - && it.soundUri == soundToUse.uri - && it.label == (message ?: "") - && it.timeInMinutes == timeInMinutes + && it.vibrate == vibrate + && it.soundTitle == soundToUse.title + && it.soundUri == soundToUse.uri + && it.label == (message ?: "") + && it.timeInMinutes == timeInMinutes } if (existingAlarm != null && !existingAlarm.isEnabled) { @@ -209,7 +229,10 @@ class IntentHandlerActivity : SimpleActivity() { if (id != null) { val alarm = dbHelper.getAlarmWithId(id) if (alarm != null) { - getDismissAlarmPendingIntent(alarm.id, EARLY_ALARM_NOTIF_ID).send() + getSkipUpcomingAlarmPendingIntent( + alarmId = alarm.id, + notificationId = UPCOMING_ALARM_NOTIFICATION_ID + ).send() EventBus.getDefault().post(AlarmEvent.Refresh) finish() } @@ -224,7 +247,9 @@ class IntentHandlerActivity : SimpleActivity() { AlarmClock.ALARM_SEARCH_MODE_TIME -> { if (hasExtra(AlarmClock.EXTRA_HOUR)) { val hour = getIntExtra(AlarmClock.EXTRA_HOUR, -1).coerceIn(0, 23) - alarms = alarms.filter { it.timeInMinutes / 60 == hour || it.timeInMinutes / 60 == hour + 12 } + alarms = alarms.filter { + it.timeInMinutes / 60 == hour || it.timeInMinutes / 60 == hour + 12 + } } if (hasExtra(AlarmClock.EXTRA_MINUTES)) { val minute = getIntExtra(AlarmClock.EXTRA_MINUTES, -1).coerceIn(0, 59) @@ -244,9 +269,9 @@ class IntentHandlerActivity : SimpleActivity() { } AlarmClock.ALARM_SEARCH_MODE_NEXT -> { - val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager val next = alarmManager.nextAlarmClock - val timeInMinutes = TimeUnit.MILLISECONDS.toMinutes(next.triggerTime).toInt() + val timeInMinutes = + TimeUnit.MILLISECONDS.toMinutes(next.triggerTime).toInt() val dayBitToLookFor = if (timeInMinutes <= getCurrentDayMinutes()) { getTomorrowBit() } else { @@ -265,7 +290,12 @@ class IntentHandlerActivity : SimpleActivity() { AlarmClock.ALARM_SEARCH_MODE_LABEL -> { val messageToSearchFor = getStringExtra(AlarmClock.EXTRA_MESSAGE) if (messageToSearchFor != null) { - alarms = alarms.filter { it.label.contains(messageToSearchFor, ignoreCase = true) } + alarms = alarms.filter { + it.label.contains( + other = messageToSearchFor, + ignoreCase = true + ) + } } } @@ -276,13 +306,23 @@ class IntentHandlerActivity : SimpleActivity() { } if (alarms.count() == 1) { - getDismissAlarmPendingIntent(alarms.first().id, EARLY_ALARM_NOTIF_ID).send() + getSkipUpcomingAlarmPendingIntent( + alarmId = alarms.first().id, + notificationId = UPCOMING_ALARM_NOTIFICATION_ID + ).send() EventBus.getDefault().post(AlarmEvent.Refresh) finish() } else if (alarms.count() > 1) { - SelectAlarmDialog(this@IntentHandlerActivity, alarms, R.string.select_alarm_to_dismiss) { + SelectAlarmDialog( + activity = this@IntentHandlerActivity, + alarms = alarms, + titleResId = R.string.select_alarm_to_dismiss + ) { if (it != null) { - getDismissAlarmPendingIntent(it.id, EARLY_ALARM_NOTIF_ID).send() + getSkipUpcomingAlarmPendingIntent( + alarmId = it.id, + notificationId = UPCOMING_ALARM_NOTIFICATION_ID + ).send() } EventBus.getDefault().post(AlarmEvent.Refresh) finish() @@ -338,16 +378,24 @@ class IntentHandlerActivity : SimpleActivity() { } private fun startAlarm(alarm: Alarm) { - scheduleNextAlarm(alarm, true) + alarmController.scheduleNextOccurrence(alarm, true) EventBus.getDefault().post(AlarmEvent.Refresh) } private fun startTimer(timer: Timer) { handleNotificationPermission { granted -> - val newState = TimerState.Running(timer.seconds.secondsToMillis, timer.seconds.secondsToMillis) + val newState = TimerState.Running( + duration = timer.seconds.secondsToMillis, + tick = timer.seconds.secondsToMillis + ) val newTimer = timer.copy(state = newState) fun notifyAndStartTimer() { - EventBus.getDefault().post(TimerEvent.Start(newTimer.id!!, newTimer.seconds.secondsToMillis)) + EventBus.getDefault().post( + TimerEvent.Start( + timerId = newTimer.id!!, + duration = newTimer.seconds.secondsToMillis + ) + ) EventBus.getDefault().post(TimerEvent.Refresh) } @@ -358,8 +406,8 @@ class IntentHandlerActivity : SimpleActivity() { } } else { PermissionRequiredDialog( - this, - org.fossify.commons.R.string.allow_notifications_reminders, + activity = this, + textId = org.fossify.commons.R.string.allow_notifications_reminders, positiveActionCallback = { openNotificationSettings() timerHelper.insertOrUpdateTimer(newTimer) { @@ -369,7 +417,8 @@ class IntentHandlerActivity : SimpleActivity() { }, negativeActionCallback = { finish() - }) + } + ) } } } diff --git a/app/src/main/kotlin/org/fossify/clock/activities/MainActivity.kt b/app/src/main/kotlin/org/fossify/clock/activities/MainActivity.kt index b3b326d8..c698c5b4 100644 --- a/app/src/main/kotlin/org/fossify/clock/activities/MainActivity.kt +++ b/app/src/main/kotlin/org/fossify/clock/activities/MainActivity.kt @@ -3,25 +3,59 @@ package org.fossify.clock.activities import android.annotation.SuppressLint import android.content.Intent import android.content.pm.ShortcutInfo -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Icon import android.graphics.drawable.LayerDrawable import android.os.Bundle import android.view.WindowManager +import androidx.core.graphics.drawable.toDrawable import me.grantland.widget.AutofitHelper import org.fossify.clock.BuildConfig import org.fossify.clock.R import org.fossify.clock.adapters.ViewPagerAdapter import org.fossify.clock.databinding.ActivityMainBinding +import org.fossify.clock.extensions.alarmController import org.fossify.clock.extensions.config import org.fossify.clock.extensions.getEnabledAlarms import org.fossify.clock.extensions.handleFullScreenNotificationsPermission -import org.fossify.clock.extensions.rescheduleEnabledAlarms import org.fossify.clock.extensions.updateWidgets -import org.fossify.clock.helpers.* +import org.fossify.clock.helpers.INVALID_TIMER_ID +import org.fossify.clock.helpers.OPEN_TAB +import org.fossify.clock.helpers.PICK_AUDIO_FILE_INTENT_ID +import org.fossify.clock.helpers.STOPWATCH_SHORTCUT_ID +import org.fossify.clock.helpers.STOPWATCH_TOGGLE_ACTION +import org.fossify.clock.helpers.TABS_COUNT +import org.fossify.clock.helpers.TAB_ALARM +import org.fossify.clock.helpers.TAB_ALARM_INDEX +import org.fossify.clock.helpers.TAB_CLOCK +import org.fossify.clock.helpers.TAB_CLOCK_INDEX +import org.fossify.clock.helpers.TAB_STOPWATCH +import org.fossify.clock.helpers.TAB_STOPWATCH_INDEX +import org.fossify.clock.helpers.TAB_TIMER +import org.fossify.clock.helpers.TAB_TIMER_INDEX +import org.fossify.clock.helpers.TIMER_ID +import org.fossify.clock.helpers.TOGGLE_STOPWATCH import org.fossify.commons.databinding.BottomTablayoutItemBinding -import org.fossify.commons.extensions.* -import org.fossify.commons.helpers.* +import org.fossify.commons.extensions.appLaunched +import org.fossify.commons.extensions.applyColorFilter +import org.fossify.commons.extensions.convertToBitmap +import org.fossify.commons.extensions.getBottomNavigationBackgroundColor +import org.fossify.commons.extensions.getProperBackgroundColor +import org.fossify.commons.extensions.getProperPrimaryColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.launchMoreAppsFromUsIntent +import org.fossify.commons.extensions.onPageChangeListener +import org.fossify.commons.extensions.onTabSelectionChanged +import org.fossify.commons.extensions.shortcutManager +import org.fossify.commons.extensions.storeNewYourAlarmSound +import org.fossify.commons.extensions.toast +import org.fossify.commons.extensions.updateBottomTabItemColors +import org.fossify.commons.extensions.viewBinding +import org.fossify.commons.helpers.LICENSE_AUTOFITTEXTVIEW +import org.fossify.commons.helpers.LICENSE_NUMBER_PICKER +import org.fossify.commons.helpers.LICENSE_RTL +import org.fossify.commons.helpers.LICENSE_STETHO +import org.fossify.commons.helpers.ensureBackgroundThread +import org.fossify.commons.helpers.isNougatMR1Plus import org.fossify.commons.models.FAQItem class MainActivity : SimpleActivity() { @@ -38,7 +72,12 @@ class MainActivity : SimpleActivity() { setupOptionsMenu() refreshMenuItems() - updateMaterialActivityViews(binding.mainCoordinator, binding.mainHolder, useTransparentNavigation = false, useTopSearchMenu = false) + updateMaterialActivityViews( + mainCoordinatorLayout = binding.mainCoordinator, + nestedView = binding.mainHolder, + useTransparentNavigation = false, + useTopSearchMenu = false + ) storeStateVariables() initFragments() @@ -46,7 +85,7 @@ class MainActivity : SimpleActivity() { updateWidgets() migrateFirstDayOfWeek() ensureBackgroundThread { - rescheduleEnabledAlarms() + alarmController.rescheduleEnabledAlarms() } getEnabledAlarms { enabledAlarms -> @@ -72,13 +111,14 @@ class MainActivity : SimpleActivity() { val configBackgroundColor = getProperBackgroundColor() if (storedBackgroundColor != configBackgroundColor) { - binding.mainTabsHolder.background = ColorDrawable(configBackgroundColor) + binding.mainTabsHolder.background = configBackgroundColor.toDrawable() } val configPrimaryColor = getProperPrimaryColor() if (storedPrimaryColor != configPrimaryColor) { binding.mainTabsHolder.setSelectedTabIndicatorColor(getProperPrimaryColor()) - binding.mainTabsHolder.getTabAt(binding.viewPager.currentItem)?.icon?.applyColorFilter(getProperPrimaryColor()) + binding.mainTabsHolder.getTabAt(binding.viewPager.currentItem)?.icon + ?.applyColorFilter(getProperPrimaryColor()) } if (config.preventPhoneFromSleeping) { @@ -107,7 +147,9 @@ class MainActivity : SimpleActivity() { private fun getLaunchStopwatchShortcut(appIconColor: Int): ShortcutInfo { val newEvent = getString(R.string.start_stopwatch) val drawable = resources.getDrawable(R.drawable.shortcut_stopwatch) - (drawable as LayerDrawable).findDrawableByLayerId(R.id.shortcut_stopwatch_background).applyColorFilter(appIconColor) + (drawable as LayerDrawable) + .findDrawableByLayerId(R.id.shortcut_stopwatch_background) + .applyColorFilter(appIconColor) val bmp = drawable.convertToBitmap() val intent = Intent(this, SplashActivity::class.java).apply { @@ -152,8 +194,10 @@ class MainActivity : SimpleActivity() { private fun refreshMenuItems() { binding.mainToolbar.menu.apply { - findItem(R.id.sort).isVisible = binding.viewPager.currentItem == getTabIndex(TAB_ALARM) || binding.viewPager.currentItem == getTabIndex(TAB_TIMER) - findItem(R.id.more_apps_from_us).isVisible = !resources.getBoolean(org.fossify.commons.R.bool.hide_google_relations) + findItem(R.id.sort).isVisible = binding.viewPager.currentItem == getTabIndex(TAB_ALARM) + || binding.viewPager.currentItem == getTabIndex(TAB_TIMER) + findItem(R.id.more_apps_from_us).isVisible = + !resources.getBoolean(org.fossify.commons.R.bool.hide_google_relations) } } @@ -235,33 +279,52 @@ class MainActivity : SimpleActivity() { R.drawable.ic_stopwatch_vector, R.drawable.ic_hourglass_vector ) - val tabLabels = arrayOf(R.string.clock, org.fossify.commons.R.string.alarm, R.string.stopwatch, R.string.timer) + val tabLabels = arrayOf( + R.string.clock, + org.fossify.commons.R.string.alarm, + R.string.stopwatch, + R.string.timer + ) tabDrawables.forEachIndexed { i, drawableId -> - binding.mainTabsHolder.newTab().setCustomView(org.fossify.commons.R.layout.bottom_tablayout_item).apply tab@{ - customView?.let { BottomTablayoutItemBinding.bind(it) }?.apply { - tabItemIcon.setImageDrawable(getDrawable(drawableId)) - tabItemLabel.setText(tabLabels[i]) - AutofitHelper.create(tabItemLabel) - binding.mainTabsHolder.addTab(this@tab) + binding.mainTabsHolder.newTab() + .setCustomView(org.fossify.commons.R.layout.bottom_tablayout_item) + .apply tab@{ + customView?.let { BottomTablayoutItemBinding.bind(it) }?.apply { + tabItemIcon.setImageDrawable(getDrawable(drawableId)) + tabItemLabel.setText(tabLabels[i]) + AutofitHelper.create(tabItemLabel) + binding.mainTabsHolder.addTab(this@tab) + } } - } } binding.mainTabsHolder.onTabSelectionChanged( tabUnselectedAction = { - updateBottomTabItemColors(it.customView, false, getDeselectedTabDrawableIds()[it.position]) + updateBottomTabItemColors( + view = it.customView, + isActive = false, + drawableId = getDeselectedTabDrawableIds()[it.position] + ) }, tabSelectedAction = { binding.viewPager.currentItem = it.position - updateBottomTabItemColors(it.customView, true, getSelectedTabDrawableIds()[it.position]) + updateBottomTabItemColors( + view = it.customView, + isActive = true, + drawableId = getSelectedTabDrawableIds()[it.position] + ) } ) } private fun setupTabColors() { val activeView = binding.mainTabsHolder.getTabAt(binding.viewPager.currentItem)?.customView - updateBottomTabItemColors(activeView, true, getSelectedTabDrawableIds()[binding.viewPager.currentItem]) + updateBottomTabItemColors( + view = activeView, + isActive = true, + drawableId = getSelectedTabDrawableIds()[binding.viewPager.currentItem] + ) getInactiveTabIndexes(binding.viewPager.currentItem).forEach { index -> val inactiveView = binding.mainTabsHolder.getTabAt(index)?.customView @@ -274,7 +337,9 @@ class MainActivity : SimpleActivity() { updateNavigationBarColor(bottomBarColor) } - private fun getInactiveTabIndexes(activeIndex: Int) = arrayListOf(0, 1, 2, 3).filter { it != activeIndex } + private fun getInactiveTabIndexes(activeIndex: Int): List { + return arrayListOf(0, 1, 2, 3).filter { it != activeIndex } + } private fun getSelectedTabDrawableIds() = arrayOf( org.fossify.commons.R.drawable.ic_clock_filled_vector, @@ -295,21 +360,50 @@ class MainActivity : SimpleActivity() { } private fun launchAbout() { - val licenses = LICENSE_STETHO or LICENSE_NUMBER_PICKER or LICENSE_RTL or LICENSE_AUTOFITTEXTVIEW + val licenses = + LICENSE_STETHO or LICENSE_NUMBER_PICKER or LICENSE_RTL or LICENSE_AUTOFITTEXTVIEW val faqItems = arrayListOf( - FAQItem(R.string.faq_1_title, R.string.faq_1_text), - FAQItem(org.fossify.commons.R.string.faq_1_title_commons, org.fossify.commons.R.string.faq_1_text_commons), - FAQItem(org.fossify.commons.R.string.faq_4_title_commons, org.fossify.commons.R.string.faq_4_text_commons), - FAQItem(org.fossify.commons.R.string.faq_9_title_commons, org.fossify.commons.R.string.faq_9_text_commons) + FAQItem( + title = R.string.faq_1_title, + text = R.string.faq_1_text + ), + FAQItem( + title = org.fossify.commons.R.string.faq_1_title_commons, + text = org.fossify.commons.R.string.faq_1_text_commons + ), + FAQItem( + title = org.fossify.commons.R.string.faq_4_title_commons, + text = org.fossify.commons.R.string.faq_4_text_commons + ), + FAQItem( + title = org.fossify.commons.R.string.faq_9_title_commons, + text = org.fossify.commons.R.string.faq_9_text_commons + ) ) if (!resources.getBoolean(org.fossify.commons.R.bool.hide_google_relations)) { - faqItems.add(FAQItem(org.fossify.commons.R.string.faq_2_title_commons, org.fossify.commons.R.string.faq_2_text_commons)) - faqItems.add(FAQItem(org.fossify.commons.R.string.faq_6_title_commons, org.fossify.commons.R.string.faq_6_text_commons)) + faqItems.add( + FAQItem( + title = org.fossify.commons.R.string.faq_2_title_commons, + text = org.fossify.commons.R.string.faq_2_text_commons + ) + ) + faqItems.add( + FAQItem( + title = org.fossify.commons.R.string.faq_6_title_commons, + text = org.fossify.commons.R.string.faq_6_text_commons + ) + ) } - startAboutActivity(R.string.app_name, licenses, BuildConfig.VERSION_NAME, faqItems, true) + startAboutActivity( + appNameId = R.string.app_name, + licenseMask = licenses, + versionName = BuildConfig.VERSION_NAME, + faqItems = faqItems, + showFAQBeforeMail = true + ) } @Deprecated("Remove this method in future releases") diff --git a/app/src/main/kotlin/org/fossify/clock/activities/ReminderActivity.kt b/app/src/main/kotlin/org/fossify/clock/activities/ReminderActivity.kt deleted file mode 100644 index f0d7d015..00000000 --- a/app/src/main/kotlin/org/fossify/clock/activities/ReminderActivity.kt +++ /dev/null @@ -1,393 +0,0 @@ -package org.fossify.clock.activities - -import android.annotation.SuppressLint -import android.content.Intent -import android.content.res.Configuration -import android.media.AudioManager -import android.media.MediaPlayer -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.os.VibrationEffect -import android.os.Vibrator -import android.provider.AlarmClock -import android.view.MotionEvent -import android.view.WindowManager -import android.view.animation.AnimationUtils -import androidx.core.net.toUri -import org.fossify.clock.R -import org.fossify.clock.databinding.ActivityReminderBinding -import org.fossify.clock.extensions.cancelAlarmClock -import org.fossify.clock.extensions.config -import org.fossify.clock.extensions.dbHelper -import org.fossify.clock.extensions.disableExpiredAlarm -import org.fossify.clock.extensions.getFormattedTime -import org.fossify.clock.extensions.scheduleNextAlarm -import org.fossify.clock.extensions.setupAlarmClock -import org.fossify.clock.helpers.ALARM_ID -import org.fossify.clock.helpers.ALARM_NOTIF_ID -import org.fossify.clock.helpers.getPassedSeconds -import org.fossify.clock.models.Alarm -import org.fossify.commons.extensions.applyColorFilter -import org.fossify.commons.extensions.beGone -import org.fossify.commons.extensions.getColoredDrawableWithColor -import org.fossify.commons.extensions.getProperBackgroundColor -import org.fossify.commons.extensions.getProperPrimaryColor -import org.fossify.commons.extensions.getProperTextColor -import org.fossify.commons.extensions.notificationManager -import org.fossify.commons.extensions.onGlobalLayout -import org.fossify.commons.extensions.performHapticFeedback -import org.fossify.commons.extensions.showPickSecondsDialog -import org.fossify.commons.extensions.updateTextColors -import org.fossify.commons.extensions.viewBinding -import org.fossify.commons.helpers.MINUTE_SECONDS -import org.fossify.commons.helpers.SILENT -import org.fossify.commons.helpers.isOreoMr1Plus -import org.fossify.commons.helpers.isOreoPlus -import java.util.Calendar -import kotlin.math.max -import kotlin.math.min - -class ReminderActivity : SimpleActivity() { - companion object { - private const val MIN_ALARM_VOLUME_FOR_INCREASING_ALARMS = 1 - private const val INCREASE_VOLUME_DELAY = 300L - } - - private val increaseVolumeHandler = Handler(Looper.getMainLooper()) - private val maxReminderDurationHandler = Handler(Looper.getMainLooper()) - private val swipeGuideFadeHandler = Handler() - private val vibrationHandler = Handler(Looper.getMainLooper()) - private var isAlarmReminder = false - private var didVibrate = false - private var wasAlarmSnoozed = false - private var alarm: Alarm? = null - private var audioManager: AudioManager? = null - private var mediaPlayer: MediaPlayer? = null - private var vibrator: Vibrator? = null - private var initialAlarmVolume: Int? = null - private var dragDownX = 0f - private val binding: ActivityReminderBinding by viewBinding(ActivityReminderBinding::inflate) - private var finished = false - - override fun onCreate(savedInstanceState: Bundle?) { - isMaterialActivity = true - super.onCreate(savedInstanceState) - setContentView(binding.root) - showOverLockscreen() - updateTextColors(binding.root) - updateStatusbarColor(getProperBackgroundColor()) - - val id = intent.getIntExtra(ALARM_ID, -1) - isAlarmReminder = id != -1 - if (id != -1) { - alarm = dbHelper.getAlarmWithId(id) ?: return - } - - val label = if (isAlarmReminder) { - if (alarm!!.label.isEmpty()) { - getString(org.fossify.commons.R.string.alarm) - } else { - alarm!!.label - } - } else { - getString(R.string.timer) - } - - binding.reminderTitle.text = label - binding.reminderText.text = if (isAlarmReminder) { - getFormattedTime( - passedSeconds = getPassedSeconds(), - showSeconds = false, - makeAmPmSmaller = false - ) - } else { - getString(R.string.time_expired) - } - - val maxDuration = if (isAlarmReminder) { - config.alarmMaxReminderSecs - } else { - config.timerMaxReminderSecs - } - - maxReminderDurationHandler.postDelayed({ - finishActivity() - cancelNotification() - }, maxDuration * 1000L) - - setupButtons() - setupEffects() - } - - private fun setupButtons() { - if (isAlarmReminder) { - setupAlarmButtons() - } else { - setupTimerButtons() - } - } - - @SuppressLint("ClickableViewAccessibility") - private fun setupAlarmButtons() { - binding.reminderStop.beGone() - binding.reminderDraggableBackground.startAnimation( - AnimationUtils.loadAnimation( - this, - R.anim.pulsing_animation - ) - ) - binding.reminderDraggableBackground.applyColorFilter(getProperPrimaryColor()) - - val textColor = getProperTextColor() - binding.reminderDismiss.applyColorFilter(textColor) - binding.reminderDraggable.applyColorFilter(textColor) - binding.reminderSnooze.applyColorFilter(textColor) - - var minDragX = 0f - var maxDragX = 0f - var initialDraggableX = 0f - - binding.reminderDismiss.onGlobalLayout { - minDragX = binding.reminderSnooze.left.toFloat() - maxDragX = binding.reminderDismiss.left.toFloat() - initialDraggableX = binding.reminderDraggable.left.toFloat() - } - - binding.reminderDraggable.setOnTouchListener { v, event -> - when (event.action) { - MotionEvent.ACTION_DOWN -> { - dragDownX = event.x - binding.reminderDraggableBackground.animate().alpha(0f) - } - - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - dragDownX = 0f - if (!didVibrate) { - binding.reminderDraggable.animate().x(initialDraggableX).withEndAction { - binding.reminderDraggableBackground.animate().alpha(0.2f) - } - - binding.reminderGuide.animate().alpha(1f).start() - swipeGuideFadeHandler.removeCallbacksAndMessages(null) - swipeGuideFadeHandler.postDelayed({ - binding.reminderGuide.animate().alpha(0f).start() - }, 2000L) - } - } - - MotionEvent.ACTION_MOVE -> { - binding.reminderDraggable.x = min( - a = maxDragX, - b = max(minDragX, event.rawX - dragDownX) - ) - - if (binding.reminderDraggable.x >= maxDragX - 50f) { - if (!didVibrate) { - binding.reminderDraggable.performHapticFeedback() - didVibrate = true - finishActivity() - } - - cancelNotification() - } else if (binding.reminderDraggable.x <= minDragX + 50f) { - if (!didVibrate) { - binding.reminderDraggable.performHapticFeedback() - didVibrate = true - snoozeAlarm() - } - - cancelNotification() - } - } - } - true - } - } - - private fun setupTimerButtons() { - binding.reminderStop.background = resources.getColoredDrawableWithColor( - drawableId = R.drawable.circle_background_filled, - color = getProperPrimaryColor() - ) - arrayOf( - binding.reminderSnooze, - binding.reminderDraggableBackground, - binding.reminderDraggable, - binding.reminderDismiss - ).forEach { - it.beGone() - } - - binding.reminderStop.setOnClickListener { - finishActivity() - } - } - - private fun setupEffects() { - audioManager = getSystemService(AUDIO_SERVICE) as AudioManager - initialAlarmVolume = audioManager?.getStreamVolume(AudioManager.STREAM_ALARM) ?: 7 - - val doVibrate = alarm?.vibrate ?: config.timerVibrate - if (doVibrate && isOreoPlus()) { - val pattern = LongArray(2) { 500 } - vibrationHandler.postDelayed({ - vibrator = getSystemService(VIBRATOR_SERVICE) as Vibrator - vibrator?.vibrate(VibrationEffect.createWaveform(pattern, 0)) - }, 500) - } - - val soundUri = if (alarm != null) { - alarm!!.soundUri - } else { - config.timerSoundUri - } - - if (soundUri != SILENT) { - try { - mediaPlayer = MediaPlayer().apply { - setAudioStreamType(AudioManager.STREAM_ALARM) - setDataSource(this@ReminderActivity, soundUri.toUri()) - isLooping = true - prepare() - start() - } - - if (config.increaseVolumeGradually) { - scheduleVolumeIncrease( - lastVolume = MIN_ALARM_VOLUME_FOR_INCREASING_ALARMS.toFloat(), - maxVolume = initialAlarmVolume!!.toFloat(), - delay = 0 - ) - } - } catch (e: Exception) { - } - } - } - - private fun scheduleVolumeIncrease(lastVolume: Float, maxVolume: Float, delay: Long) { - increaseVolumeHandler.postDelayed({ - val newLastVolume = (lastVolume + 0.1f).coerceAtMost(maxVolume) - audioManager?.setStreamVolume(AudioManager.STREAM_ALARM, newLastVolume.toInt(), 0) - scheduleVolumeIncrease(newLastVolume, maxVolume, INCREASE_VOLUME_DELAY) - }, delay) - } - - private fun resetVolumeToInitialValue() { - initialAlarmVolume?.apply { - audioManager?.setStreamVolume(AudioManager.STREAM_ALARM, this, 0) - } - } - - override fun onConfigurationChanged(newConfig: Configuration) { - super.onConfigurationChanged(newConfig) - setupAlarmButtons() - } - - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) - if (intent?.action == AlarmClock.ACTION_SNOOZE_ALARM) { - val durationMinutes = intent.getIntExtra(AlarmClock.EXTRA_ALARM_SNOOZE_DURATION, -1) - if (durationMinutes == -1) { - snoozeAlarm() - } else { - snoozeAlarm(durationMinutes) - } - } else { - finishActivity() - } - } - - override fun onDestroy() { - super.onDestroy() - increaseVolumeHandler.removeCallbacksAndMessages(null) - maxReminderDurationHandler.removeCallbacksAndMessages(null) - swipeGuideFadeHandler.removeCallbacksAndMessages(null) - vibrationHandler.removeCallbacksAndMessages(null) - if (!finished) { - finishActivity() - cancelNotification() - } else { - destroyEffects() - } - } - - private fun destroyEffects() { - if (config.increaseVolumeGradually) { - resetVolumeToInitialValue() - } - - mediaPlayer?.stop() - mediaPlayer?.release() - mediaPlayer = null - vibrator?.cancel() - vibrator = null - } - - private fun snoozeAlarm(overrideSnoozeDuration: Int? = null) { - destroyEffects() - if (overrideSnoozeDuration != null) { - scheduleSnoozedAlarm(overrideSnoozeDuration) - } else if (config.useSameSnooze) { - scheduleSnoozedAlarm(config.snoozeTime) - } else { - showPickSecondsDialog( - curSeconds = config.snoozeTime * MINUTE_SECONDS, - isSnoozePicker = true, - cancelCallback = { - finishActivity() - }, - callback = { - config.snoozeTime = it / MINUTE_SECONDS - scheduleSnoozedAlarm(config.snoozeTime) - } - ) - } - } - - private fun scheduleSnoozedAlarm(snoozeMinutes: Int) { - setupAlarmClock( - alarm = alarm!!, - triggerTimeMillis = Calendar.getInstance() - .apply { add(Calendar.MINUTE, snoozeMinutes) } - .timeInMillis - ) - - wasAlarmSnoozed = true - finishActivity() - } - - private fun finishActivity() { - if (!wasAlarmSnoozed && alarm != null) { - cancelAlarmClock(alarm!!) - if (alarm!!.days > 0) { - scheduleNextAlarm(alarm!!, false) - } - - disableExpiredAlarm(alarm!!) - } - - finished = true - destroyEffects() - finish() - overridePendingTransition(0, 0) - } - - private fun showOverLockscreen() { - window.addFlags( - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or - WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD or - WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON - ) - - if (isOreoMr1Plus()) { - setShowWhenLocked(true) - setTurnScreenOn(true) - } - } - - private fun cancelNotification() { - notificationManager.cancel(ALARM_NOTIF_ID) - } -} diff --git a/app/src/main/kotlin/org/fossify/clock/activities/SettingsActivity.kt b/app/src/main/kotlin/org/fossify/clock/activities/SettingsActivity.kt index 2ce673f1..1b34b658 100644 --- a/app/src/main/kotlin/org/fossify/clock/activities/SettingsActivity.kt +++ b/app/src/main/kotlin/org/fossify/clock/activities/SettingsActivity.kt @@ -10,10 +10,9 @@ import org.fossify.clock.R import org.fossify.clock.databinding.ActivitySettingsBinding import org.fossify.clock.dialogs.ExportDataDialog import org.fossify.clock.extensions.config -import org.fossify.clock.extensions.updateWidgets import org.fossify.clock.extensions.dbHelper import org.fossify.clock.extensions.timerDb -import org.fossify.clock.helpers.DBHelper +import org.fossify.clock.extensions.updateWidgets import org.fossify.clock.helpers.DEFAULT_MAX_ALARM_REMINDER_SECS import org.fossify.clock.helpers.DEFAULT_MAX_TIMER_REMINDER_SECS import org.fossify.clock.helpers.EXPORT_BACKUP_MIME_TYPE @@ -50,7 +49,6 @@ import org.fossify.commons.helpers.TAB_LAST_USED import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.helpers.isTiramisuPlus import org.fossify.commons.models.RadioItem -import java.io.IOException import java.util.Locale import kotlin.system.exitProcess @@ -60,11 +58,7 @@ class SettingsActivity : SimpleActivity() { registerForActivityResult(ActivityResultContracts.CreateDocument(EXPORT_BACKUP_MIME_TYPE)) { uri -> if (uri == null) return@registerForActivityResult ensureBackgroundThread { - try { - exportDataTo(uri) - } catch (e: IOException) { - showErrorToast(e) - } + exportData(uri) } } @@ -72,11 +66,7 @@ class SettingsActivity : SimpleActivity() { registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri == null) return@registerForActivityResult ensureBackgroundThread { - try { - importData(uri) - } catch (e: Exception) { - showErrorToast(e) - } + importData(uri) } } @@ -218,12 +208,14 @@ class SettingsActivity : SimpleActivity() { RadioItem(5, getString(org.fossify.commons.R.string.saturday)), ) - binding.settingsStartWeekOn.text = resources.getStringArray(org.fossify.commons.R.array.week_days)[config.firstDayOfWeek] + binding.settingsStartWeekOn.text = + resources.getStringArray(org.fossify.commons.R.array.week_days)[config.firstDayOfWeek] binding.settingsStartWeekOnHolder.setOnClickListener { RadioGroupDialog(this@SettingsActivity, items, config.firstDayOfWeek) { day -> val firstDayOfWeek = day as Int config.firstDayOfWeek = firstDayOfWeek - binding.settingsStartWeekOn.text = resources.getStringArray(org.fossify.commons.R.array.week_days)[config.firstDayOfWeek] + binding.settingsStartWeekOn.text = + resources.getStringArray(org.fossify.commons.R.array.week_days)[config.firstDayOfWeek] } } } @@ -231,7 +223,11 @@ class SettingsActivity : SimpleActivity() { private fun setupAlarmMaxReminder() { updateAlarmMaxReminderText() binding.settingsAlarmMaxReminderHolder.setOnClickListener { - showPickSecondsDialog(config.alarmMaxReminderSecs, true, true) { + showPickSecondsDialog( + curSeconds = config.alarmMaxReminderSecs, + isSnoozePicker = true, + showSecondsAtCustomDialog = true + ) { config.alarmMaxReminderSecs = if (it != 0) it else DEFAULT_MAX_ALARM_REMINDER_SECS updateAlarmMaxReminderText() } @@ -251,7 +247,10 @@ class SettingsActivity : SimpleActivity() { private fun setupSnoozeTime() { updateSnoozeText() binding.settingsSnoozeTimeHolder.setOnClickListener { - showPickSecondsDialog(config.snoozeTime * MINUTE_SECONDS, true) { + showPickSecondsDialog( + curSeconds = config.snoozeTime * MINUTE_SECONDS, + isSnoozePicker = true + ) { config.snoozeTime = it / MINUTE_SECONDS updateSnoozeText() } @@ -261,7 +260,11 @@ class SettingsActivity : SimpleActivity() { private fun setupTimerMaxReminder() { updateTimerMaxReminderText() binding.settingsTimerMaxReminderHolder.setOnClickListener { - showPickSecondsDialog(config.timerMaxReminderSecs, true, true) { + showPickSecondsDialog( + curSeconds = config.timerMaxReminderSecs, + isSnoozePicker = true, + showSecondsAtCustomDialog = true + ) { config.timerMaxReminderSecs = if (it != 0) it else DEFAULT_MAX_TIMER_REMINDER_SECS updateTimerMaxReminderText() } @@ -311,7 +314,7 @@ class SettingsActivity : SimpleActivity() { } } - private fun exportDataTo(outputUri: Uri) { + private fun exportData(outputUri: Uri) { val alarms = dbHelper.getAlarms() val timers = timerDb.getTimers() if (alarms.isEmpty() && timers.isEmpty()) { @@ -335,7 +338,7 @@ class SettingsActivity : SimpleActivity() { ExportDataDialog(this, config.lastDataExportPath) { file -> try { exportActivityResultLauncher.launch(file.name) - } catch (e: ActivityNotFoundException) { + } catch (@Suppress("SwallowedException") e: ActivityNotFoundException) { toast( id = org.fossify.commons.R.string.system_service_disabled, length = Toast.LENGTH_LONG @@ -349,7 +352,7 @@ class SettingsActivity : SimpleActivity() { private fun tryImportData() { try { importActivityResultLauncher.launch(IMPORT_BACKUP_MIME_TYPES.toTypedArray()) - } catch (e: ActivityNotFoundException) { + } catch (@Suppress("SwallowedException") e: ActivityNotFoundException) { toast(org.fossify.commons.R.string.system_service_disabled, Toast.LENGTH_LONG) } catch (e: Exception) { showErrorToast(e) @@ -359,7 +362,7 @@ class SettingsActivity : SimpleActivity() { private fun importData(uri: Uri) { val result = ImportHelper( context = this, - dbHelper = DBHelper.dbInstance!!, + dbHelper = dbHelper, timerHelper = TimerHelper(this) ).importData(uri) diff --git a/app/src/main/kotlin/org/fossify/clock/activities/SnoozeReminderActivity.kt b/app/src/main/kotlin/org/fossify/clock/activities/SnoozeReminderActivity.kt index 7f80b392..7439e73c 100644 --- a/app/src/main/kotlin/org/fossify/clock/activities/SnoozeReminderActivity.kt +++ b/app/src/main/kotlin/org/fossify/clock/activities/SnoozeReminderActivity.kt @@ -2,29 +2,27 @@ package org.fossify.clock.activities import android.os.Bundle import androidx.appcompat.app.AppCompatActivity +import org.fossify.clock.extensions.alarmController import org.fossify.clock.extensions.config -import org.fossify.clock.extensions.dbHelper -import org.fossify.clock.extensions.hideNotification -import org.fossify.clock.extensions.setupAlarmClock import org.fossify.clock.helpers.ALARM_ID import org.fossify.commons.extensions.showPickSecondsDialog import org.fossify.commons.helpers.MINUTE_SECONDS -import java.util.Calendar class SnoozeReminderActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val id = intent.getIntExtra(ALARM_ID, -1) - val alarm = dbHelper.getAlarmWithId(id) ?: return - hideNotification(id) - showPickSecondsDialog(config.snoozeTime * MINUTE_SECONDS, true, cancelCallback = { dialogCancelled() }) { + val alarmId = intent.getIntExtra(ALARM_ID, -1) + alarmController.stopAlarm(alarmId = alarmId, disable = false) + showPickSecondsDialog( + curSeconds = config.snoozeTime * MINUTE_SECONDS, + isSnoozePicker = true, + cancelCallback = { + alarmController.stopAlarm(alarmId) + dialogCancelled() + } + ) { config.snoozeTime = it / MINUTE_SECONDS - setupAlarmClock( - alarm = alarm, - triggerTimeMillis = Calendar.getInstance() - .apply { add(Calendar.SECOND, it) } - .timeInMillis - ) + alarmController.snoozeAlarm(alarmId, config.snoozeTime) finishActivity() } } diff --git a/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt b/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt index 407a7f60..bccbe0c9 100644 --- a/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt +++ b/app/src/main/kotlin/org/fossify/clock/adapters/AlarmsAdapter.kt @@ -15,7 +15,6 @@ import org.fossify.clock.extensions.dbHelper import org.fossify.clock.extensions.getAlarmSelectedDaysString import org.fossify.clock.extensions.getFormattedTime import org.fossify.clock.extensions.swap -import org.fossify.clock.helpers.TODAY_BIT import org.fossify.clock.helpers.TOMORROW_BIT import org.fossify.clock.helpers.getCurrentDayMinutes import org.fossify.clock.interfaces.ToggleAlarmInterface @@ -95,7 +94,11 @@ class AlarmsAdapter( override fun onBindViewHolder(holder: ViewHolder, position: Int) { val alarm = alarms[position] - holder.bindView(alarm, true, true) { itemView, _ -> + holder.bindView( + any = alarm, + allowSingleClick = true, + allowLongClick = true + ) { itemView, _ -> setupView(itemView, alarm, holder) } bindViewHolder(holder) @@ -139,7 +142,11 @@ class AlarmsAdapter( } false } - alarmTime.text = activity.getFormattedTime(alarm.timeInMinutes * 60, false, true) + alarmTime.text = activity.getFormattedTime( + passedSeconds = alarm.timeInMinutes * 60, + showSeconds = false, + makeAmPmSmaller = true + ) alarmTime.setTextColor(textColor) alarmDays.text = activity.getAlarmSelectedDaysString(alarm.days) @@ -152,47 +159,51 @@ class AlarmsAdapter( alarmSwitch.isChecked = alarm.isEnabled alarmSwitch.setColors(textColor, properPrimaryColor, backgroundColor) alarmSwitch.setOnClickListener { - when { - alarm.days > 0 -> { - if (activity.config.wasAlarmWarningShown) { - toggleAlarmInterface.alarmToggled(alarm.id, alarmSwitch.isChecked) - } else { - ConfirmationDialog( - activity = activity, - messageId = org.fossify.commons.R.string.alarm_warning, - positive = org.fossify.commons.R.string.ok, - negative = 0 - ) { - activity.config.wasAlarmWarningShown = true - toggleAlarmInterface.alarmToggled(alarm.id, alarmSwitch.isChecked) - } - } - } + toggleAlarm(binding = this, alarm = alarm) + } + } + } - alarm.days == TODAY_BIT -> { - if (alarm.timeInMinutes <= getCurrentDayMinutes()) { - alarm.days = TOMORROW_BIT - alarmDays.text = - resources.getString(org.fossify.commons.R.string.tomorrow) - } - activity.dbHelper.updateAlarm(alarm) - toggleAlarmInterface.alarmToggled(alarm.id, alarmSwitch.isChecked) + private fun toggleAlarm(binding: ItemAlarmBinding, alarm: Alarm) { + when { + alarm.isRecurring() -> { + if (activity.config.wasAlarmWarningShown) { + toggleAlarmInterface.alarmToggled(alarm.id, binding.alarmSwitch.isChecked) + } else { + ConfirmationDialog( + activity = activity, + messageId = org.fossify.commons.R.string.alarm_warning, + positive = org.fossify.commons.R.string.ok, + negative = 0 + ) { + activity.config.wasAlarmWarningShown = true + toggleAlarmInterface.alarmToggled(alarm.id, binding.alarmSwitch.isChecked) } + } + } - alarm.days == TOMORROW_BIT -> { - toggleAlarmInterface.alarmToggled(alarm.id, alarmSwitch.isChecked) - } + alarm.isToday() -> { + if (alarm.timeInMinutes <= getCurrentDayMinutes()) { + alarm.days = TOMORROW_BIT + binding.alarmDays.text = + resources.getString(org.fossify.commons.R.string.tomorrow) + } + activity.dbHelper.updateAlarm(alarm) + toggleAlarmInterface.alarmToggled(alarm.id, binding.alarmSwitch.isChecked) + } - // Unreachable zombie branch. Days are always set to a non-zero value. - alarmSwitch.isChecked -> { - activity.toast(R.string.no_days_selected) - alarmSwitch.isChecked = false - } + alarm.isTomorrow() -> { + toggleAlarmInterface.alarmToggled(alarm.id, binding.alarmSwitch.isChecked) + } - else -> { - toggleAlarmInterface.alarmToggled(alarm.id, alarmSwitch.isChecked) - } - } + // Unreachable zombie branch. Days are always set to a non-zero value. + binding.alarmSwitch.isChecked -> { + activity.toast(R.string.no_days_selected) + binding.alarmSwitch.isChecked = false + } + + else -> { + toggleAlarmInterface.alarmToggled(alarm.id, binding.alarmSwitch.isChecked) } } } diff --git a/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt b/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt index 4f8f1eb0..adac6d4f 100644 --- a/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt +++ b/app/src/main/kotlin/org/fossify/clock/dialogs/EditAlarmDialog.kt @@ -4,7 +4,6 @@ import android.app.TimePickerDialog import android.graphics.drawable.Drawable import android.media.AudioManager import android.media.RingtoneManager -import android.text.format.DateFormat import android.widget.TextView import androidx.appcompat.app.AlertDialog import com.google.android.material.timepicker.MaterialTimePicker @@ -12,7 +11,13 @@ import com.google.android.material.timepicker.TimeFormat import org.fossify.clock.R import org.fossify.clock.activities.SimpleActivity import org.fossify.clock.databinding.DialogEditAlarmBinding -import org.fossify.clock.extensions.* +import org.fossify.clock.extensions.checkAlarmsWithDeletedSoundUri +import org.fossify.clock.extensions.colorCompoundDrawable +import org.fossify.clock.extensions.config +import org.fossify.clock.extensions.dbHelper +import org.fossify.clock.extensions.getFormattedTime +import org.fossify.clock.extensions.handleFullScreenNotificationsPermission +import org.fossify.clock.extensions.orderDaysList import org.fossify.clock.helpers.PICK_AUDIO_FILE_INTENT_ID import org.fossify.clock.helpers.TODAY_BIT import org.fossify.clock.helpers.TOMORROW_BIT @@ -20,10 +25,26 @@ import org.fossify.clock.helpers.getCurrentDayMinutes import org.fossify.clock.models.Alarm import org.fossify.commons.dialogs.ConfirmationDialog import org.fossify.commons.dialogs.SelectAlarmSoundDialog -import org.fossify.commons.extensions.* +import org.fossify.commons.extensions.addBit +import org.fossify.commons.extensions.applyColorFilter +import org.fossify.commons.extensions.beVisibleIf +import org.fossify.commons.extensions.getAlertDialogBuilder +import org.fossify.commons.extensions.getDefaultAlarmSound +import org.fossify.commons.extensions.getProperBackgroundColor +import org.fossify.commons.extensions.getProperTextColor +import org.fossify.commons.extensions.getTimePickerDialogTheme +import org.fossify.commons.extensions.removeBit +import org.fossify.commons.extensions.setupDialogStuff +import org.fossify.commons.extensions.toast +import org.fossify.commons.extensions.value import org.fossify.commons.models.AlarmSound -class EditAlarmDialog(val activity: SimpleActivity, val alarm: Alarm, val onDismiss: () -> Unit = {}, val callback: (alarmId: Int) -> Unit) { +class EditAlarmDialog( + val activity: SimpleActivity, + val alarm: Alarm, + val onDismiss: () -> Unit = {}, + val callback: (alarmId: Int) -> Unit, +) { private val binding = DialogEditAlarmBinding.inflate(activity.layoutInflater) private val textColor = activity.getProperTextColor() @@ -67,14 +88,22 @@ class EditAlarmDialog(val activity: SimpleActivity, val alarm: Alarm, val onDism editAlarmSound.colorCompoundDrawable(textColor) editAlarmSound.text = alarm.soundTitle editAlarmSound.setOnClickListener { - SelectAlarmSoundDialog(activity, alarm.soundUri, AudioManager.STREAM_ALARM, PICK_AUDIO_FILE_INTENT_ID, RingtoneManager.TYPE_ALARM, true, + SelectAlarmSoundDialog( + activity = activity, + currentUri = alarm.soundUri, + audioStream = AudioManager.STREAM_ALARM, + pickAudioIntentId = PICK_AUDIO_FILE_INTENT_ID, + type = RingtoneManager.TYPE_ALARM, + loopAudio = true, onAlarmPicked = { if (it != null) { updateSelectedAlarmSound(it) } - }, onAlarmSoundDeleted = { + }, + onAlarmSoundDeleted = { if (alarm.soundUri == it.uri) { - val defaultAlarm = root.context.getDefaultAlarmSound(RingtoneManager.TYPE_ALARM) + val defaultAlarm = + root.context.getDefaultAlarmSound(RingtoneManager.TYPE_ALARM) updateSelectedAlarmSound(defaultAlarm) } activity.checkAlarmsWithDeletedSoundUri(it.uri) @@ -91,28 +120,32 @@ class EditAlarmDialog(val activity: SimpleActivity, val alarm: Alarm, val onDism editAlarmLabelImage.applyColorFilter(textColor) editAlarm.setText(alarm.label) - val dayLetters = activity.resources.getStringArray(org.fossify.commons.R.array.week_day_letters).toList() as ArrayList + val dayLetters = + activity.resources.getStringArray(org.fossify.commons.R.array.week_day_letters) + .toList() as ArrayList val dayIndexes = activity.orderDaysList(arrayListOf(0, 1, 2, 3, 4, 5, 6)) dayIndexes.forEach { - val pow = Math.pow(2.0, it.toDouble()).toInt() - val day = activity.layoutInflater.inflate(R.layout.alarm_day, editAlarmDaysHolder, false) as TextView + val bitmask = 1 shl it + val day = activity.layoutInflater.inflate( + R.layout.alarm_day, editAlarmDaysHolder, false + ) as TextView day.text = dayLetters[it] - val isDayChecked = alarm.days > 0 && alarm.days and pow != 0 + val isDayChecked = alarm.isRecurring() && alarm.days and bitmask != 0 day.background = getProperDayDrawable(isDayChecked) day.setTextColor(if (isDayChecked) root.context.getProperBackgroundColor() else textColor) day.setOnClickListener { - if (alarm.days < 0) { + if (!alarm.isRecurring()) { alarm.days = 0 } - val selectDay = alarm.days and pow == 0 + val selectDay = alarm.days and bitmask == 0 if (selectDay) { - alarm.days = alarm.days.addBit(pow) + alarm.days = alarm.days.addBit(bitmask) } else { - alarm.days = alarm.days.removeBit(pow) + alarm.days = alarm.days.removeBit(bitmask) } day.background = getProperDayDrawable(selectDay) day.setTextColor(if (selectDay) root.context.getProperBackgroundColor() else textColor) @@ -132,7 +165,7 @@ class EditAlarmDialog(val activity: SimpleActivity, val alarm: Alarm, val onDism alertDialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { if (!activity.config.wasAlarmWarningShown) { ConfirmationDialog( - activity, + activity = activity, messageId = org.fossify.commons.R.string.alarm_warning, positive = org.fossify.commons.R.string.ok, negative = 0 @@ -193,7 +226,7 @@ class EditAlarmDialog(val activity: SimpleActivity, val alarm: Alarm, val onDism } } - private val timeSetListener = TimePickerDialog.OnTimeSetListener { view, hourOfDay, minute -> + private val timeSetListener = TimePickerDialog.OnTimeSetListener { _, hourOfDay, minute -> timePicked(hourOfDay, minute) } @@ -203,7 +236,11 @@ class EditAlarmDialog(val activity: SimpleActivity, val alarm: Alarm, val onDism } private fun updateAlarmTime() { - binding.editAlarmTime.text = activity.getFormattedTime(alarm.timeInMinutes * 60, false, true) + binding.editAlarmTime.text = activity.getFormattedTime( + passedSeconds = alarm.timeInMinutes * 60, + showSeconds = false, + makeAmPmSmaller = true + ) checkDaylessAlarm() } @@ -221,7 +258,12 @@ class EditAlarmDialog(val activity: SimpleActivity, val alarm: Alarm, val onDism } private fun getProperDayDrawable(selected: Boolean): Drawable { - val drawableId = if (selected) R.drawable.circle_background_filled else R.drawable.circle_background_stroke + val drawableId = if (selected) { + R.drawable.circle_background_filled + } else { + R.drawable.circle_background_stroke + } + val drawable = activity.resources.getDrawable(drawableId) drawable.applyColorFilter(textColor) return drawable diff --git a/app/src/main/kotlin/org/fossify/clock/extensions/BroadcastReceiver.kt b/app/src/main/kotlin/org/fossify/clock/extensions/BroadcastReceiver.kt new file mode 100644 index 00000000..213dc303 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/extensions/BroadcastReceiver.kt @@ -0,0 +1,20 @@ +package org.fossify.clock.extensions + +import android.content.BroadcastReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + +fun BroadcastReceiver.goAsync(callback: suspend () -> Unit) { + val pendingResult = goAsync() + coroutineScope.launch { + try { + callback() + } finally { + pendingResult.finish() + } + } +} diff --git a/app/src/main/kotlin/org/fossify/clock/extensions/Context.kt b/app/src/main/kotlin/org/fossify/clock/extensions/Context.kt index c3e1ab46..ffd03b69 100644 --- a/app/src/main/kotlin/org/fossify/clock/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/clock/extensions/Context.kt @@ -14,7 +14,6 @@ import android.media.AudioManager.STREAM_ALARM import android.media.RingtoneManager import android.os.Handler import android.os.Looper -import android.os.PowerManager import android.text.SpannableString import android.text.style.RelativeSizeSpan import android.widget.Toast @@ -22,12 +21,11 @@ import androidx.core.app.AlarmManagerCompat import androidx.core.app.NotificationCompat import androidx.core.net.toUri import org.fossify.clock.R -import org.fossify.clock.activities.ReminderActivity import org.fossify.clock.activities.SnoozeReminderActivity import org.fossify.clock.activities.SplashActivity import org.fossify.clock.databases.AppDatabase import org.fossify.clock.helpers.ALARM_ID -import org.fossify.clock.helpers.ALARM_NOTIFICATION_CHANNEL_ID +import org.fossify.clock.helpers.AlarmController import org.fossify.clock.helpers.Config import org.fossify.clock.helpers.DBHelper import org.fossify.clock.helpers.EARLY_ALARM_DISMISSAL_INTENT_ID @@ -40,7 +38,6 @@ import org.fossify.clock.helpers.NOTIFICATION_ID import org.fossify.clock.helpers.OPEN_ALARMS_TAB_INTENT_ID import org.fossify.clock.helpers.OPEN_STOPWATCH_TAB_INTENT_ID import org.fossify.clock.helpers.OPEN_TAB -import org.fossify.clock.helpers.REMINDER_ACTIVITY_INTENT_ID import org.fossify.clock.helpers.TAB_ALARM import org.fossify.clock.helpers.TAB_STOPWATCH import org.fossify.clock.helpers.TAB_TIMER @@ -50,21 +47,19 @@ import org.fossify.clock.helpers.TOMORROW_BIT import org.fossify.clock.helpers.TimerHelper import org.fossify.clock.helpers.formatTime import org.fossify.clock.helpers.getAllTimeZones -import org.fossify.clock.helpers.getCurrentDayMinutes import org.fossify.clock.helpers.getDefaultTimeZoneTitle -import org.fossify.clock.helpers.getPassedSeconds import org.fossify.clock.helpers.getTimeOfNextAlarm import org.fossify.clock.interfaces.TimerDao import org.fossify.clock.models.Alarm -import org.fossify.clock.models.AlarmEvent import org.fossify.clock.models.MyTimeZone import org.fossify.clock.models.Timer import org.fossify.clock.models.TimerState import org.fossify.clock.receivers.AlarmReceiver -import org.fossify.clock.receivers.DismissAlarmReceiver -import org.fossify.clock.receivers.EarlyAlarmDismissalReceiver -import org.fossify.clock.receivers.HideAlarmReceiver import org.fossify.clock.receivers.HideTimerReceiver +import org.fossify.clock.receivers.SkipUpcomingAlarmReceiver +import org.fossify.clock.receivers.StopAlarmReceiver +import org.fossify.clock.receivers.UpcomingAlarmReceiver +import org.fossify.clock.services.AlarmService import org.fossify.clock.services.SnoozeService import org.fossify.commons.extensions.formatMinutesToTimeString import org.fossify.commons.extensions.formatSecondsToTimeString @@ -73,6 +68,7 @@ import org.fossify.commons.extensions.getLaunchIntent import org.fossify.commons.extensions.getProperPrimaryColor import org.fossify.commons.extensions.getSelectedDaysString import org.fossify.commons.extensions.grantReadUriPermission +import org.fossify.commons.extensions.notificationManager import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.extensions.toInt import org.fossify.commons.extensions.toast @@ -88,7 +84,6 @@ import org.fossify.commons.helpers.TUESDAY_BIT import org.fossify.commons.helpers.WEDNESDAY_BIT import org.fossify.commons.helpers.ensureBackgroundThread import org.fossify.commons.helpers.isOreoPlus -import org.greenrobot.eventbus.EventBus import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -96,14 +91,26 @@ import kotlin.math.ceil import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes -val Context.config: Config get() = Config.newInstance(applicationContext) +val Context.config: Config + get() = Config.newInstance(applicationContext) -val Context.dbHelper: DBHelper get() = DBHelper.newInstance(applicationContext) -val Context.timerDb: TimerDao get() = AppDatabase.getInstance(applicationContext).TimerDao() -val Context.timerHelper: TimerHelper get() = TimerHelper(this) +val Context.dbHelper: DBHelper + get() = DBHelper.newInstance(applicationContext) + +val Context.timerDb: TimerDao + get() = AppDatabase.getInstance(applicationContext).TimerDao() + +val Context.timerHelper: TimerHelper + get() = TimerHelper(this) + +val Context.alarmManager: AlarmManager + get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager + +val Context.alarmController: AlarmController + get() = AlarmController.getInstance(applicationContext) fun Context.getFormattedDate(calendar: Calendar): String { - val dayOfWeek = (calendar.get(Calendar.DAY_OF_WEEK) + 5) % 7 // make sure index 0 means monday + val dayOfWeek = (calendar.get(Calendar.DAY_OF_WEEK) + 5) % 7 // make sure index 0 means monday val dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH) val month = calendar.get(Calendar.MONTH) @@ -135,38 +142,39 @@ fun Context.getAllTimeZonesModified(): ArrayList { return timeZones } -fun Context.getModifiedTimeZoneTitle(id: Int) = getAllTimeZonesModified().firstOrNull { it.id == id }?.title ?: getDefaultTimeZoneTitle(id) +fun Context.getModifiedTimeZoneTitle(id: Int): String { + return getAllTimeZonesModified() + .firstOrNull { it.id == id }?.title ?: getDefaultTimeZoneTitle(id) +} fun Context.createNewAlarm(timeInMinutes: Int, weekDays: Int): Alarm { val defaultAlarmSound = getDefaultAlarmSound(RingtoneManager.TYPE_ALARM) - return Alarm(0, timeInMinutes, weekDays, false, false, defaultAlarmSound.title, defaultAlarmSound.uri, "") + return Alarm( + id = 0, + timeInMinutes = timeInMinutes, + days = weekDays, + isEnabled = false, + vibrate = false, + soundTitle = defaultAlarmSound.title, + soundUri = defaultAlarmSound.uri, + label = "" + ) } fun Context.createNewTimer(): Timer { return Timer( - null, - config.timerSeconds, - TimerState.Idle, - config.timerVibrate, - config.timerSoundUri, - config.timerSoundTitle, - config.timerLabel ?: "", - System.currentTimeMillis(), - config.timerChannelId, + id = null, + seconds = config.timerSeconds, + state = TimerState.Idle, + vibrate = config.timerVibrate, + soundUri = config.timerSoundUri, + soundTitle = config.timerSoundTitle, + label = config.timerLabel ?: "", + createdAt = System.currentTimeMillis(), + channelId = config.timerChannelId, ) } -fun Context.scheduleNextAlarm(alarm: Alarm, showToast: Boolean) { - val triggerTimeMillis = getTimeOfNextAlarm(alarm)?.timeInMillis ?: return - setupAlarmClock(alarm = alarm, triggerTimeMillis = triggerTimeMillis) - - if (showToast) { - val now = Calendar.getInstance() - val triggerInMillis = triggerTimeMillis - now.timeInMillis - showRemainingTimeMessage(triggerInMillis) - } -} - fun Context.showRemainingTimeMessage(triggerInMillis: Long) { val totalSeconds = triggerInMillis.milliseconds.inWholeSeconds.toInt() val remainingTime = if (totalSeconds >= MINUTE_SECONDS) { @@ -185,74 +193,100 @@ fun Context.showRemainingTimeMessage(triggerInMillis: Long) { } fun Context.setupAlarmClock(alarm: Alarm, triggerTimeMillis: Long) { - val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager - + val alarmManager = alarmManager try { - AlarmManagerCompat.setAlarmClock(alarmManager, triggerTimeMillis, getOpenAlarmTabIntent(), getAlarmIntent(alarm)) + AlarmManagerCompat.setAlarmClock( + alarmManager, + triggerTimeMillis, + getOpenAlarmTabIntent(), + getAlarmIntent(alarm) + ) // show a notification to allow dismissing the alarm 10 minutes before it actually triggers - val dismissalTriggerTime = if (triggerTimeMillis - System.currentTimeMillis() < 10.minutes.inWholeMilliseconds) { - System.currentTimeMillis() + 500 - } else { - triggerTimeMillis - 10.minutes.inWholeMilliseconds - } - AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, 0, dismissalTriggerTime, getEarlyAlarmDismissalIntent(alarm)) + val dismissalTriggerTime = + if (triggerTimeMillis - System.currentTimeMillis() < 10.minutes.inWholeMilliseconds) { + System.currentTimeMillis() + 500 + } else { + triggerTimeMillis - 10.minutes.inWholeMilliseconds + } + + AlarmManagerCompat.setExactAndAllowWhileIdle( + alarmManager, + 0, + dismissalTriggerTime, + getUpcomingAlarmPendingIntent(alarm) + ) } catch (e: Exception) { showErrorToast(e) } } -fun Context.getEarlyAlarmDismissalIntent(alarm: Alarm): PendingIntent { - val intent = Intent(this, EarlyAlarmDismissalReceiver::class.java).apply { +fun Context.getUpcomingAlarmPendingIntent(alarm: Alarm): PendingIntent { + val intent = Intent(this, UpcomingAlarmReceiver::class.java).apply { putExtra(ALARM_ID, alarm.id) } - return PendingIntent.getBroadcast(this, EARLY_ALARM_DISMISSAL_INTENT_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + + return PendingIntent.getBroadcast( + this, + EARLY_ALARM_DISMISSAL_INTENT_ID, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } fun Context.getOpenAlarmTabIntent(): PendingIntent { val intent = getLaunchIntent() ?: Intent(this, SplashActivity::class.java) intent.putExtra(OPEN_TAB, TAB_ALARM) - return PendingIntent.getActivity(this, OPEN_ALARMS_TAB_INTENT_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getActivity( + this, + OPEN_ALARMS_TAB_INTENT_ID, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } fun Context.getOpenTimerTabIntent(timerId: Int): PendingIntent { val intent = getLaunchIntent() ?: Intent(this, SplashActivity::class.java) intent.putExtra(OPEN_TAB, TAB_TIMER) intent.putExtra(TIMER_ID, timerId) - return PendingIntent.getActivity(this, timerId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getActivity( + this, + timerId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } fun Context.getOpenStopwatchTabIntent(): PendingIntent { val intent = getLaunchIntent() ?: Intent(this, SplashActivity::class.java) intent.putExtra(OPEN_TAB, TAB_STOPWATCH) - return PendingIntent.getActivity(this, OPEN_STOPWATCH_TAB_INTENT_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getActivity( + this, + OPEN_STOPWATCH_TAB_INTENT_ID, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } fun Context.getAlarmIntent(alarm: Alarm): PendingIntent { val intent = Intent(this, AlarmReceiver::class.java) intent.putExtra(ALARM_ID, alarm.id) - return PendingIntent.getBroadcast(this, alarm.id, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getBroadcast( + this, + alarm.id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } fun Context.cancelAlarmClock(alarm: Alarm) { - val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val alarmManager = alarmManager alarmManager.cancel(getAlarmIntent(alarm)) - alarmManager.cancel(getEarlyAlarmDismissalIntent(alarm)) + alarmManager.cancel(getUpcomingAlarmPendingIntent(alarm)) } fun Context.hideNotification(id: Int) { - val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.cancel(id) -} - -fun Context.deleteNotificationChannel(channelId: String) { - if (isOreoPlus()) { - try { - val manager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.deleteNotificationChannel(channelId) - } catch (_: Throwable) { - } - } + notificationManager.cancel(id) } fun Context.hideTimerNotification(timerId: Int) = hideNotification(timerId) @@ -264,7 +298,9 @@ fun Context.updateWidgets() { fun Context.updateDigitalWidgets() { val component = ComponentName(applicationContext, MyDigitalTimeWidgetProvider::class.java) - val widgetIds = AppWidgetManager.getInstance(applicationContext)?.getAppWidgetIds(component) ?: return + val widgetIds = AppWidgetManager.getInstance(applicationContext) + ?.getAppWidgetIds(component) ?: return + if (widgetIds.isNotEmpty()) { val ids = intArrayOf(R.xml.widget_digital_clock_info) Intent(applicationContext, MyDigitalTimeWidgetProvider::class.java).apply { @@ -277,7 +313,9 @@ fun Context.updateDigitalWidgets() { fun Context.updateAnalogueWidgets() { val component = ComponentName(applicationContext, MyAnalogueTimeWidgetProvider::class.java) - val widgetIds = AppWidgetManager.getInstance(applicationContext)?.getAppWidgetIds(component) ?: return + val widgetIds = AppWidgetManager.getInstance(applicationContext) + ?.getAppWidgetIds(component) ?: return + if (widgetIds.isNotEmpty()) { val ids = intArrayOf(R.xml.widget_analogue_clock_info) Intent(applicationContext, MyAnalogueTimeWidgetProvider::class.java).apply { @@ -288,26 +326,57 @@ fun Context.updateAnalogueWidgets() { } } -fun Context.getFormattedTime(passedSeconds: Int, showSeconds: Boolean, makeAmPmSmaller: Boolean): SpannableString { +fun Context.getFormattedTime( + passedSeconds: Int, + showSeconds: Boolean, + makeAmPmSmaller: Boolean, +): SpannableString { val use24HourFormat = config.use24HourFormat val hours = (passedSeconds / 3600) % 24 val minutes = (passedSeconds / 60) % 60 val seconds = passedSeconds % 60 return if (use24HourFormat) { - val formattedTime = formatTime(showSeconds, use24HourFormat, hours, minutes, seconds) + val formattedTime = formatTime( + showSeconds = showSeconds, + use24HourFormat = true, + hours = hours, + minutes = minutes, + seconds = seconds + ) SpannableString(formattedTime) } else { - val formattedTime = formatTo12HourFormat(showSeconds, hours, minutes, seconds) + val formattedTime = formatTo12HourFormat( + showSeconds = showSeconds, + hours = hours, + minutes = minutes, + seconds = seconds + ) val spannableTime = SpannableString(formattedTime) val amPmMultiplier = if (makeAmPmSmaller) 0.4f else 1f - spannableTime.setSpan(RelativeSizeSpan(amPmMultiplier), spannableTime.length - 3, spannableTime.length, 0) + spannableTime.setSpan( + RelativeSizeSpan(amPmMultiplier), + spannableTime.length - 3, + spannableTime.length, + 0 + ) spannableTime } } -fun Context.formatTo12HourFormat(showSeconds: Boolean, hours: Int, minutes: Int, seconds: Int): String { - val appendable = getString(if (hours >= 12) org.fossify.commons.R.string.p_m else org.fossify.commons.R.string.a_m) +fun Context.formatTo12HourFormat( + showSeconds: Boolean, + hours: Int, + minutes: Int, + seconds: Int, +): String { + val appendable = getString( + if (hours >= 12) { + org.fossify.commons.R.string.p_m + } else { + org.fossify.commons.R.string.a_m + } + ) val newHours = if (hours == 0 || hours == 12) 12 else hours % 12 return "${formatTime(showSeconds, false, newHours, minutes, seconds)} $appendable" } @@ -331,14 +400,16 @@ fun Context.getClosestEnabledAlarmString(callback: (result: String) -> Unit) { } val dayOfWeekIndex = (closestAlarmTime.get(Calendar.DAY_OF_WEEK) + 5) % 7 - val dayOfWeek = resources.getStringArray(org.fossify.commons.R.array.week_days_short)[dayOfWeekIndex] + val dayOfWeek = + resources.getStringArray(org.fossify.commons.R.array.week_days_short)[dayOfWeekIndex] val pattern = if (config.use24HourFormat) { FORMAT_24H } else { FORMAT_12H } - val formattedTime = SimpleDateFormat(pattern, Locale.getDefault()).format(closestAlarmTime.time) + val formattedTime = + SimpleDateFormat(pattern, Locale.getDefault()).format(closestAlarmTime.time) callback("$dayOfWeek $formattedTime") } } @@ -352,32 +423,7 @@ fun Context.getEnabledAlarms(callback: (result: List?) -> Unit) { } } -fun Context.rescheduleEnabledAlarms() { - dbHelper.getEnabledAlarms().forEach { - if (it.days != TODAY_BIT || it.timeInMinutes > getCurrentDayMinutes()) { - scheduleNextAlarm(it, false) - } - } -} - -fun Context.isScreenOn() = (getSystemService(Context.POWER_SERVICE) as PowerManager).isScreenOn - -fun Context.showAlarmNotification(alarm: Alarm) { - val pendingIntent = getOpenAlarmTabIntent() - val notification = getAlarmNotification(pendingIntent, alarm) - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - try { - notificationManager.notify(alarm.id, notification) - } catch (e: Exception) { - showErrorToast(e) - } - - if (alarm.days > 0) { - scheduleNextAlarm(alarm, false) - } -} - -fun Context.getTimerNotification(timer: Timer, pendingIntent: PendingIntent, addDeleteIntent: Boolean): Notification { +fun Context.getTimerNotification(timer: Timer, pendingIntent: PendingIntent): Notification { var soundUri = timer.soundUri if (soundUri == SILENT) { soundUri = "" @@ -385,8 +431,8 @@ fun Context.getTimerNotification(timer: Timer, pendingIntent: PendingIntent, add grantReadUriPermission(soundUri) } - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val channelId = timer.channelId ?: "simple_timer_channel_${soundUri}_${System.currentTimeMillis()}" + val channelId = + timer.channelId ?: "simple_timer_channel_${soundUri}_${System.currentTimeMillis()}" timerHelper.insertOrUpdateTimer(timer.copy(channelId = channelId)) if (isOreoPlus()) { @@ -418,11 +464,7 @@ fun Context.getTimerNotification(timer: Timer, pendingIntent: PendingIntent, add } } - val title = timer.label.ifEmpty { - getString(R.string.timer) - } - - val reminderActivityIntent = getReminderActivityIntent() + val title = timer.label.ifEmpty { getString(R.string.timer) } val builder = NotificationCompat.Builder(this) .setContentTitle(title) .setContentText(getString(R.string.time_expired)) @@ -431,23 +473,14 @@ fun Context.getTimerNotification(timer: Timer, pendingIntent: PendingIntent, add .setPriority(NotificationCompat.PRIORITY_MAX) .setDefaults(Notification.DEFAULT_LIGHTS) .setCategory(Notification.CATEGORY_EVENT) - .setAutoCancel(true) .setSound(soundUri.toUri(), STREAM_ALARM) .setChannelId(channelId) .addAction( org.fossify.commons.R.drawable.ic_cross_vector, getString(org.fossify.commons.R.string.dismiss), - if (addDeleteIntent) { - reminderActivityIntent - } else { - getHideTimerPendingIntent(timer.id!!) - } + getHideTimerPendingIntent(timer.id!!) ) - if (addDeleteIntent) { - builder.setDeleteIntent(reminderActivityIntent) - } - builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) if (timer.vibrate) { @@ -463,104 +496,65 @@ fun Context.getTimerNotification(timer: Timer, pendingIntent: PendingIntent, add fun Context.getHideTimerPendingIntent(timerId: Int): PendingIntent { val intent = Intent(this, HideTimerReceiver::class.java) intent.putExtra(TIMER_ID, timerId) - return PendingIntent.getBroadcast(this, timerId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getBroadcast( + this, + timerId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } -fun Context.getHideAlarmPendingIntent(alarm: Alarm, channelId: String): PendingIntent { - val intent = Intent(this, HideAlarmReceiver::class.java).apply { +fun Context.getStopAlarmPendingIntent(alarm: Alarm): PendingIntent { + val intent = Intent(this, StopAlarmReceiver::class.java).apply { putExtra(ALARM_ID, alarm.id) - putExtra(ALARM_NOTIFICATION_CHANNEL_ID, channelId) } - return PendingIntent.getBroadcast(this, alarm.id, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getBroadcast( + this, + alarm.id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } -fun Context.getDismissAlarmPendingIntent(alarmId: Int, notificationId: Int): PendingIntent { - val intent = Intent(this, DismissAlarmReceiver::class.java).apply { +fun Context.getSkipUpcomingAlarmPendingIntent(alarmId: Int, notificationId: Int): PendingIntent { + val intent = Intent(this, SkipUpcomingAlarmReceiver::class.java).apply { putExtra(ALARM_ID, alarmId) putExtra(NOTIFICATION_ID, notificationId) } - return PendingIntent.getBroadcast(this, alarmId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + return PendingIntent.getBroadcast( + this, + alarmId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } -fun Context.getAlarmNotification(pendingIntent: PendingIntent, alarm: Alarm): Notification { - val soundUri = alarm.soundUri - if (soundUri != SILENT) { - grantReadUriPermission(soundUri) - } - val channelId = "simple_alarm_channel_${soundUri}_${alarm.vibrate}" - val label = alarm.label.ifEmpty { - getString(org.fossify.commons.R.string.alarm) - } - - if (isOreoPlus()) { - val audioAttributes = AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ALARM) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setLegacyStreamType(STREAM_ALARM) - .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) - .build() - - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val importance = NotificationManager.IMPORTANCE_HIGH - NotificationChannel(channelId, label, importance).apply { - setBypassDnd(true) - enableLights(true) - lightColor = getProperPrimaryColor() - enableVibration(alarm.vibrate) - setSound(soundUri.toUri(), audioAttributes) - notificationManager.createNotificationChannel(this) - } - } - - val dismissIntent = getHideAlarmPendingIntent(alarm, channelId) - val builder = NotificationCompat.Builder(this) - .setContentTitle(label) - .setContentText(getFormattedTime(getPassedSeconds(), false, false)) - .setSmallIcon(R.drawable.ic_alarm_vector) - .setContentIntent(pendingIntent) - .setPriority(Notification.PRIORITY_HIGH) - .setDefaults(Notification.DEFAULT_LIGHTS) - .setAutoCancel(true) - .setChannelId(channelId) - .addAction( - org.fossify.commons.R.drawable.ic_snooze_vector, - getString(org.fossify.commons.R.string.snooze), - getSnoozePendingIntent(alarm) - ) - .addAction(org.fossify.commons.R.drawable.ic_cross_vector, getString(org.fossify.commons.R.string.dismiss), dismissIntent) - .setDeleteIntent(dismissIntent) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - - if (soundUri != SILENT) { - builder.setSound(soundUri.toUri(), STREAM_ALARM) - } - - if (alarm.vibrate) { - val vibrateArray = LongArray(2) { 500 } - builder.setVibrate(vibrateArray) +fun Context.getSnoozePendingIntent(alarm: Alarm): PendingIntent { + val snoozeClass = if (config.useSameSnooze) { + SnoozeService::class.java + } else { + SnoozeReminderActivity::class.java } - val notification = builder.build() - notification.flags = notification.flags or Notification.FLAG_INSISTENT - return notification -} - -fun Context.getSnoozePendingIntent(alarm: Alarm): PendingIntent { - val snoozeClass = if (config.useSameSnooze) SnoozeService::class.java else SnoozeReminderActivity::class.java val intent = Intent(this, snoozeClass).setAction("Snooze") intent.putExtra(ALARM_ID, alarm.id) return if (config.useSameSnooze) { - PendingIntent.getService(this, alarm.id, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + PendingIntent.getService( + this, + alarm.id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } else { - PendingIntent.getActivity(this, alarm.id, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + PendingIntent.getActivity( + this, + alarm.id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) } } -fun Context.getReminderActivityIntent(): PendingIntent { - val intent = Intent(this, ReminderActivity::class.java) - return PendingIntent.getActivity(this, REMINDER_ACTIVITY_INTENT_ID, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) -} - fun Context.checkAlarmsWithDeletedSoundUri(uri: String) { val defaultAlarmSound = getDefaultAlarmSound(RingtoneManager.TYPE_ALARM) dbHelper.getAlarmsWithUri(uri).forEach { @@ -592,7 +586,17 @@ fun Context.firstDayOrder(bitMask: Int): Int { if (bitMask == TODAY_BIT) return -2 if (bitMask == TOMORROW_BIT) return -1 - val dayBits = orderDaysList(arrayListOf(MONDAY_BIT, TUESDAY_BIT, WEDNESDAY_BIT, THURSDAY_BIT, FRIDAY_BIT, SATURDAY_BIT, SUNDAY_BIT)) + val dayBits = orderDaysList( + arrayListOf( + MONDAY_BIT, + TUESDAY_BIT, + WEDNESDAY_BIT, + THURSDAY_BIT, + FRIDAY_BIT, + SATURDAY_BIT, + SUNDAY_BIT + ) + ) dayBits.forEachIndexed { i, bit -> if (bitMask and bit != 0) { @@ -603,16 +607,26 @@ fun Context.firstDayOrder(bitMask: Int): Int { return bitMask } -fun Context.disableExpiredAlarm(alarm: Alarm) { - if (alarm.days < 0) { - if (alarm.oneShot) { - alarm.isEnabled = false - dbHelper.deleteAlarms(arrayListOf(alarm)) - } else { - dbHelper.updateAlarmEnabledState(alarm.id, false) +fun Context.startAlarmService(alarmId: Int) { + try { + Intent(this, AlarmService::class.java).apply { + putExtra(ALARM_ID, alarmId) + if (isOreoPlus()) { + startForegroundService(this) + } else { + startService(this) + } } + } catch (e: Exception) { + showErrorToast(e) + } +} - updateWidgets() - EventBus.getDefault().post(AlarmEvent.Refresh) +fun Context.stopAlarmService() { + try { + val serviceIntent = Intent(this, AlarmService::class.java) + stopService(serviceIntent) + } catch (e: Exception) { + showErrorToast(e) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/fossify/clock/fragments/AlarmFragment.kt b/app/src/main/kotlin/org/fossify/clock/fragments/AlarmFragment.kt index 5af7b6e9..4838deaa 100644 --- a/app/src/main/kotlin/org/fossify/clock/fragments/AlarmFragment.kt +++ b/app/src/main/kotlin/org/fossify/clock/fragments/AlarmFragment.kt @@ -11,13 +11,13 @@ import org.fossify.clock.adapters.AlarmsAdapter import org.fossify.clock.databinding.FragmentAlarmBinding import org.fossify.clock.dialogs.ChangeAlarmSortDialog import org.fossify.clock.dialogs.EditAlarmDialog +import org.fossify.clock.extensions.alarmController import org.fossify.clock.extensions.cancelAlarmClock import org.fossify.clock.extensions.config import org.fossify.clock.extensions.createNewAlarm import org.fossify.clock.extensions.dbHelper import org.fossify.clock.extensions.firstDayOrder import org.fossify.clock.extensions.handleFullScreenNotificationsPermission -import org.fossify.clock.extensions.scheduleNextAlarm import org.fossify.clock.extensions.updateWidgets import org.fossify.clock.helpers.DEFAULT_ALARM_MINUTES import org.fossify.clock.helpers.SORT_BY_ALARM_TIME @@ -195,7 +195,7 @@ class AlarmFragment : Fragment(), ToggleAlarmInterface { private fun checkAlarmState(alarm: Alarm) { if (alarm.isEnabled) { - context?.scheduleNextAlarm(alarm, true) + context?.alarmController?.scheduleNextOccurrence(alarm, true) } else { context?.cancelAlarmClock(alarm) } diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/AlarmController.kt b/app/src/main/kotlin/org/fossify/clock/helpers/AlarmController.kt new file mode 100644 index 00000000..17d6ddc8 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/helpers/AlarmController.kt @@ -0,0 +1,220 @@ +package org.fossify.clock.helpers + +import android.app.Application +import android.content.Context +import org.fossify.clock.extensions.cancelAlarmClock +import org.fossify.clock.extensions.dbHelper +import org.fossify.clock.extensions.setupAlarmClock +import org.fossify.clock.extensions.showRemainingTimeMessage +import org.fossify.clock.extensions.startAlarmService +import org.fossify.clock.extensions.stopAlarmService +import org.fossify.clock.extensions.updateWidgets +import org.fossify.clock.models.Alarm +import org.fossify.clock.models.AlarmEvent +import org.fossify.commons.extensions.removeBit +import org.fossify.commons.helpers.ensureBackgroundThread +import org.greenrobot.eventbus.EventBus +import java.util.Calendar + +/** + * Centralized class for handling alarm operations including dismissal, cancellation, scheduling, + * and state management. + */ +class AlarmController( + private val context: Application, + private val db: DBHelper, + private val bus: EventBus, +) { + /** + * Reschedules all enabled alarms with the exception of one-time alarms that were scheduled + * for today (to avoid rescheduling skipped upcoming alarms, yeah). + */ + fun rescheduleEnabledAlarms() { + db.getEnabledAlarms().forEach { + if (!it.isToday() || it.timeInMinutes > getCurrentDayMinutes()) { + scheduleNextOccurrence(it, false) + } + } + } + + /** + * Schedules the next occurrence of a repeating alarm based on its repetition rules. + * + * @param alarm The alarm to schedule. + * @param showToasts If true, a remaining time toast will be shown for the alarm. + */ + fun scheduleNextOccurrence(alarm: Alarm, showToasts: Boolean = false) { + ensureBackgroundThread { + scheduleNextAlarm(alarm, showToasts) + notifyObservers() + } + } + + /** + * Skips (cancels) the *next scheduled occurrence* of an alarm before it rings. + * If the alarm is repeating, it cancels the upcoming alert and schedules the *following* + * occurrence based on repetition rules. If the alarm is a one-time alarm, it cancels and + * disables or deletes it. + * + * @param alarmId The ID of the upcoming alarm trigger to skip/cancel. + */ + fun skipNextOccurrence(alarmId: Int) { + ensureBackgroundThread { + val alarm = db.getAlarmWithId(alarmId) ?: return@ensureBackgroundThread + context.cancelAlarmClock(alarm) + + // Schedule the *next* occurrence based on the original repeating schedule. + if (alarm.isRecurring()) { + // TODO: This is a bit of a hack. Skipped alarms should be tracked properly. + val todayBitmask = getTodayBit() + if (alarm.days and todayBitmask != 0) { + // If there are other days set, schedule based on those remaining days. + val remainingDays = alarm.days.removeBit(todayBitmask) + if (remainingDays > 0) { + val alarmForScheduling = alarm.copy(days = remainingDays) + scheduleNextAlarm(alarmForScheduling) + } else { + // Today was the ONLY weekday set. Skipping it means no weekdays are left. + // TODO: But does this mean the alarm won't be scheduled for next week? + } + } else { + // Not scheduled for today anyway, just reschedule the alarm. + scheduleNextAlarm(alarm) + } + } else { + disableOrDeleteOneTimeAlarm(alarm) + } + + notifyObservers() + } + } + + /** + * Handles the triggering of an alarm, scheduling the next occurrence and starting the service + * for sounding the alarm. + * + * @param alarmId The ID of the alarm that was triggered. + */ + fun onAlarmTriggered(alarmId: Int) { + ensureBackgroundThread { + // Reschedule the next occurrence right away + val alarm = db.getAlarmWithId(alarmId) ?: return@ensureBackgroundThread + if (alarm.isRecurring()) { + scheduleNextOccurrence(alarm) + } + } + + context.startAlarmService(alarmId) + } + + /** + * Dismisses an alarm that is currently ringing or has just finished ringing. + * + * - Stops the alarm sound/vibration service ([stopAlarmService]). + * - If the alarm is *not* repeating and the `disable` parameter is true, the alarm is + * disabled or deleted via [disableOrDeleteOneTimeAlarm]. + * + * @param alarmId The ID of the alarm to dismiss. + * @param disable If true and the alarm is a one-time alarm, it will be disabled or deleted. + * This parameter has no effect on repeating alarms. + */ + fun stopAlarm(alarmId: Int, disable: Boolean = true) { + context.stopAlarmService() + bus.post(AlarmEvent.Stopped(alarmId)) + + ensureBackgroundThread { + val alarm = db.getAlarmWithId(alarmId) + + // We don't reschedule alarms here. + if (alarm != null && !alarm.isRecurring() && disable) { + context.cancelAlarmClock(alarm) + disableOrDeleteOneTimeAlarm(alarm) + } + + notifyObservers() + } + } + + /** + * Snoozes an alarm that is currently ringing. + * + * - Stops the alarm sound/vibration service ([stopAlarmService]). + * - Schedules the alarm to ring again after [snoozeMinutes] using [setupAlarmClock] + * with a calculated future trigger time. + * + * TODO: This works but it is very rudimentary. Snoozed alarms should be tracked properly. + * + * @param alarmId The ID of the alarm to snooze. + * @param snoozeMinutes The number of minutes from now until the alarm should ring again. + */ + fun snoozeAlarm(alarmId: Int, snoozeMinutes: Int) { + context.stopAlarmService() + bus.post(AlarmEvent.Stopped(alarmId)) + + ensureBackgroundThread { + val alarm = db.getAlarmWithId(alarmId) + if (alarm != null) { + context.setupAlarmClock( + alarm = alarm, + triggerTimeMillis = Calendar.getInstance() + .apply { add(Calendar.MINUTE, snoozeMinutes) } + .timeInMillis + ) + } + + notifyObservers() + } + } + + /** + * Handles disabling or deleting a *one-time* (non-repeating) alarm based on `oneShot` property. + * This is typically called after a one-time alarm has rung and been dismissed or stopped, + * or when it's explicitly skipped. + * + * @param alarm The one-time alarm to disable or delete. Must not be repeating. + */ + private fun disableOrDeleteOneTimeAlarm(alarm: Alarm) { + require(!alarm.isRecurring()) { + "Alarm ${alarm.id} is repeating but was passed to disableOrDeleteOneTimeAlarm()" + } + + if (alarm.oneShot) { + alarm.isEnabled = false + db.deleteAlarms(arrayListOf(alarm)) + } else { + db.updateAlarmEnabledState(alarm.id, false) + } + } + + private fun scheduleNextAlarm(alarm: Alarm, showToast: Boolean = false) { + val triggerTimeMillis = getTimeOfNextAlarm(alarm)?.timeInMillis ?: return + context.setupAlarmClock(alarm = alarm, triggerTimeMillis = triggerTimeMillis) + + if (showToast) { + val now = Calendar.getInstance() + val triggerInMillis = triggerTimeMillis - now.timeInMillis + context.showRemainingTimeMessage(triggerInMillis) + } + } + + private fun notifyObservers() { + context.updateWidgets() + bus.post(AlarmEvent.Refresh) + } + + companion object { + @Volatile + private var instance: AlarmController? = null + + fun getInstance(context: Context): AlarmController { + val appContext = context.applicationContext as Application + return instance ?: synchronized(this) { + instance ?: AlarmController( + context = appContext, + db = appContext.dbHelper, + bus = EventBus.getDefault() + ).also { instance = it } + } + } + } +} diff --git a/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt index bfce9b5e..6ddb6104 100644 --- a/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/clock/helpers/Constants.kt @@ -14,7 +14,6 @@ import org.fossify.commons.helpers.isPiePlus import java.util.Calendar import java.util.Date import java.util.TimeZone -import kotlin.math.pow // shared preferences const val SELECTED_TIME_ZONES = "selected_time_zones" @@ -54,14 +53,13 @@ const val EARLY_ALARM_DISMISSAL_CHANNEL_ID = "Early Alarm Dismissal" const val OPEN_STOPWATCH_TAB_INTENT_ID = 9993 const val PICK_AUDIO_FILE_INTENT_ID = 9994 -const val REMINDER_ACTIVITY_INTENT_ID = 9995 const val OPEN_ALARMS_TAB_INTENT_ID = 9996 const val OPEN_APP_INTENT_ID = 9997 const val ALARM_NOTIF_ID = 9998 const val TIMER_RUNNING_NOTIF_ID = 10000 const val STOPWATCH_RUNNING_NOTIF_ID = 10001 const val EARLY_ALARM_DISMISSAL_INTENT_ID = 10002 -const val EARLY_ALARM_NOTIF_ID = 10003 +const val UPCOMING_ALARM_NOTIFICATION_ID = 10003 const val OPEN_TAB = "open_tab" const val TAB_CLOCK = 1 @@ -157,13 +155,13 @@ fun getTomorrowBit(): Int { val calendar = Calendar.getInstance() calendar.add(Calendar.DAY_OF_WEEK, 1) val dayOfWeek = getDayNumber(calendar.get(Calendar.DAY_OF_WEEK)) - return 2.0.pow(dayOfWeek).toInt() + return 1 shl dayOfWeek } fun getTodayBit(): Int { val calendar = Calendar.getInstance() val dayOfWeek = getDayNumber(calendar.get(Calendar.DAY_OF_WEEK)) - return 2.0.pow(dayOfWeek).toInt() + return 1 shl dayOfWeek } fun getBitForCalendarDay(day: Int): Int { @@ -274,7 +272,6 @@ fun getTimeOfNextAlarm(alarm: Alarm): Calendar? { fun getTimeOfNextAlarm(alarmTimeInMinutes: Int, days: Int): Calendar? { val nextAlarmTime = Calendar.getInstance().apply { - firstDayOfWeek = Calendar.MONDAY // why is this here? seems unnecessary set(Calendar.HOUR_OF_DAY, alarmTimeInMinutes / 60) set(Calendar.MINUTE, alarmTimeInMinutes % 60) set(Calendar.SECOND, 0) diff --git a/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt b/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt index 9d068411..263ca1fa 100644 --- a/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt +++ b/app/src/main/kotlin/org/fossify/clock/models/Alarm.kt @@ -1,6 +1,8 @@ package org.fossify.clock.models import androidx.annotation.Keep +import org.fossify.clock.helpers.TODAY_BIT +import org.fossify.clock.helpers.TOMORROW_BIT @Keep @kotlinx.serialization.Serializable @@ -14,7 +16,13 @@ data class Alarm( var soundUri: String, var label: String, var oneShot: Boolean = false, -) +) { + fun isRecurring() = days > 0 + + fun isToday() = days == TODAY_BIT + + fun isTomorrow() = days == TOMORROW_BIT +} @Keep data class ObfuscatedAlarm( diff --git a/app/src/main/kotlin/org/fossify/clock/models/AlarmEvent.kt b/app/src/main/kotlin/org/fossify/clock/models/AlarmEvent.kt index 6d98634a..3d6c3893 100644 --- a/app/src/main/kotlin/org/fossify/clock/models/AlarmEvent.kt +++ b/app/src/main/kotlin/org/fossify/clock/models/AlarmEvent.kt @@ -1,5 +1,6 @@ package org.fossify.clock.models sealed interface AlarmEvent { - object Refresh : AlarmEvent + data object Refresh : AlarmEvent + data class Stopped(val alarmId: Int) : AlarmEvent } diff --git a/app/src/main/kotlin/org/fossify/clock/receivers/AlarmReceiver.kt b/app/src/main/kotlin/org/fossify/clock/receivers/AlarmReceiver.kt index a0799fb9..f06bfd26 100644 --- a/app/src/main/kotlin/org/fossify/clock/receivers/AlarmReceiver.kt +++ b/app/src/main/kotlin/org/fossify/clock/receivers/AlarmReceiver.kt @@ -1,101 +1,31 @@ package org.fossify.clock.receivers -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.PendingIntent.FLAG_IMMUTABLE -import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.Build -import android.os.Handler -import android.os.Looper -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import org.fossify.clock.R -import org.fossify.clock.activities.ReminderActivity -import org.fossify.clock.extensions.config -import org.fossify.clock.extensions.dbHelper -import org.fossify.clock.extensions.disableExpiredAlarm +import org.fossify.clock.extensions.alarmController +import org.fossify.clock.extensions.goAsync import org.fossify.clock.extensions.hideNotification -import org.fossify.clock.extensions.isScreenOn -import org.fossify.clock.extensions.showAlarmNotification import org.fossify.clock.helpers.ALARM_ID -import org.fossify.clock.helpers.ALARM_NOTIFICATION_CHANNEL_ID -import org.fossify.clock.helpers.ALARM_NOTIF_ID -import org.fossify.clock.helpers.EARLY_ALARM_NOTIF_ID -import org.fossify.commons.extensions.notificationManager -import org.fossify.commons.extensions.showErrorToast -import org.fossify.commons.helpers.isOreoPlus +import org.fossify.clock.helpers.UPCOMING_ALARM_NOTIFICATION_ID +/** + * Receiver responsible for sounding alarms. It is also responsible for hiding the + * upcoming alarm notification and scheduling the next occurrence. + */ class AlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val id = intent.getIntExtra(ALARM_ID, -1) - val alarm = context.dbHelper.getAlarmWithId(id) ?: return + if (id == -1) return - // Hide early dismissal notification if not already dismissed - context.hideNotification(EARLY_ALARM_NOTIF_ID) - - if (context.isScreenOn()) { - context.showAlarmNotification(alarm) - Handler(Looper.getMainLooper()).postDelayed({ - context.hideNotification(id) - context.disableExpiredAlarm(alarm) - }, context.config.alarmMaxReminderSecs * 1000L) - } else { - if (isOreoPlus()) { - val notificationManager = context.notificationManager - if (notificationManager.getNotificationChannel(ALARM_NOTIFICATION_CHANNEL_ID) == null) { - // cleans up previous notification channel that had sound properties - oldNotificationChannelCleanup(notificationManager) - - NotificationChannel( - ALARM_NOTIFICATION_CHANNEL_ID, - "Alarm", - NotificationManager.IMPORTANCE_HIGH - ).apply { - setBypassDnd(true) - setSound(null, null) - notificationManager.createNotificationChannel(this) - } - } - - val reminderIntent = Intent(context, ReminderActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - putExtra(ALARM_ID, id) - } - - val pendingIntent = PendingIntent.getActivity( - context, 0, reminderIntent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE - ) - - val builder = NotificationCompat.Builder(context, ALARM_NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_alarm_vector) - .setContentTitle(context.getString(org.fossify.commons.R.string.alarm)) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_ALARM) - .setFullScreenIntent(pendingIntent, true) - - try { - notificationManager.notify(ALARM_NOTIF_ID, builder.build()) - } catch (e: Exception) { - context.showErrorToast(e) - } - } else { - Intent(context, ReminderActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - putExtra(ALARM_ID, id) - context.startActivity(this) - } - } + cancelUpcomingAlarmNotification(context) + goAsync { + context.alarmController.onAlarmTriggered(id) } } - @RequiresApi(Build.VERSION_CODES.O) - private fun oldNotificationChannelCleanup(notificationManager: NotificationManager) { - notificationManager.deleteNotificationChannel("Alarm") + private fun cancelUpcomingAlarmNotification(context: Context) { + context.hideNotification(UPCOMING_ALARM_NOTIFICATION_ID) } } diff --git a/app/src/main/kotlin/org/fossify/clock/receivers/BootCompletedReceiver.kt b/app/src/main/kotlin/org/fossify/clock/receivers/BootCompletedReceiver.kt index e4b40e0c..98c34114 100644 --- a/app/src/main/kotlin/org/fossify/clock/receivers/BootCompletedReceiver.kt +++ b/app/src/main/kotlin/org/fossify/clock/receivers/BootCompletedReceiver.kt @@ -3,11 +3,14 @@ package org.fossify.clock.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import org.fossify.clock.extensions.rescheduleEnabledAlarms +import org.fossify.clock.extensions.alarmController +import org.fossify.clock.extensions.goAsync class BootCompletedReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - context.rescheduleEnabledAlarms() + goAsync { + context.alarmController.rescheduleEnabledAlarms() + } } } diff --git a/app/src/main/kotlin/org/fossify/clock/receivers/DismissAlarmReceiver.kt b/app/src/main/kotlin/org/fossify/clock/receivers/DismissAlarmReceiver.kt deleted file mode 100644 index 80e9f0a1..00000000 --- a/app/src/main/kotlin/org/fossify/clock/receivers/DismissAlarmReceiver.kt +++ /dev/null @@ -1,52 +0,0 @@ -package org.fossify.clock.receivers - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import org.fossify.clock.extensions.cancelAlarmClock -import org.fossify.clock.extensions.disableExpiredAlarm -import org.fossify.clock.extensions.dbHelper -import org.fossify.clock.extensions.hideNotification -import org.fossify.clock.extensions.scheduleNextAlarm -import org.fossify.clock.helpers.ALARM_ID -import org.fossify.clock.helpers.NOTIFICATION_ID -import org.fossify.clock.models.Alarm -import org.fossify.commons.extensions.removeBit -import org.fossify.commons.helpers.ensureBackgroundThread -import java.util.Calendar -import kotlin.math.pow - -class DismissAlarmReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val alarmId = intent.getIntExtra(ALARM_ID, -1) - val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) - if (alarmId == -1) { - return - } - - context.hideNotification(notificationId) - - ensureBackgroundThread { - context.dbHelper.getAlarmWithId(alarmId)?.let { alarm -> - context.cancelAlarmClock(alarm) - scheduleNextAlarm(alarm, context) - context.disableExpiredAlarm(alarm) - } - } - } - - private fun scheduleNextAlarm(alarm: Alarm, context: Context) { - val oldBitmask = alarm.days - alarm.days = removeTodayFromBitmask(oldBitmask) - context.scheduleNextAlarm(alarm, false) - alarm.days = oldBitmask - } - - private fun removeTodayFromBitmask(bitmask: Int): Int { - val calendar = Calendar.getInstance() - calendar.firstDayOfWeek = Calendar.MONDAY - val dayOfWeek = (calendar.get(Calendar.DAY_OF_WEEK) + 5) % 7 - val todayBitmask = 2.0.pow(dayOfWeek).toInt() - return bitmask.removeBit(todayBitmask) - } -} diff --git a/app/src/main/kotlin/org/fossify/clock/receivers/HideAlarmReceiver.kt b/app/src/main/kotlin/org/fossify/clock/receivers/HideAlarmReceiver.kt deleted file mode 100644 index 0edd6dac..00000000 --- a/app/src/main/kotlin/org/fossify/clock/receivers/HideAlarmReceiver.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.fossify.clock.receivers - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import org.fossify.clock.extensions.disableExpiredAlarm -import org.fossify.clock.extensions.dbHelper -import org.fossify.clock.extensions.deleteNotificationChannel -import org.fossify.clock.extensions.hideNotification -import org.fossify.clock.helpers.ALARM_ID -import org.fossify.clock.helpers.ALARM_NOTIFICATION_CHANNEL_ID -import org.fossify.commons.helpers.ensureBackgroundThread - -class HideAlarmReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val id = intent.getIntExtra(ALARM_ID, -1) - val channelId = intent.getStringExtra(ALARM_NOTIFICATION_CHANNEL_ID) - channelId?.let { context.deleteNotificationChannel(channelId) } - context.hideNotification(id) - - ensureBackgroundThread { - val alarm = context.dbHelper.getAlarmWithId(id) - if (alarm != null) { - context.disableExpiredAlarm(alarm) - } - } - } -} diff --git a/app/src/main/kotlin/org/fossify/clock/receivers/SkipUpcomingAlarmReceiver.kt b/app/src/main/kotlin/org/fossify/clock/receivers/SkipUpcomingAlarmReceiver.kt new file mode 100644 index 00000000..15eec78a --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/receivers/SkipUpcomingAlarmReceiver.kt @@ -0,0 +1,29 @@ +package org.fossify.clock.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.fossify.clock.extensions.alarmController +import org.fossify.clock.extensions.goAsync +import org.fossify.clock.extensions.hideNotification +import org.fossify.clock.helpers.ALARM_ID +import org.fossify.clock.helpers.NOTIFICATION_ID + +/** + * Receiver responsible for dismissing *UPCOMING* alarms. + */ +class SkipUpcomingAlarmReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val alarmId = intent.getIntExtra(ALARM_ID, -1) + if (alarmId != -1) { + goAsync { + context.alarmController.skipNextOccurrence(alarmId) + } + } + + val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) + if (notificationId != -1) { + context.hideNotification(notificationId) + } + } +} diff --git a/app/src/main/kotlin/org/fossify/clock/receivers/StopAlarmReceiver.kt b/app/src/main/kotlin/org/fossify/clock/receivers/StopAlarmReceiver.kt new file mode 100644 index 00000000..2ba3c95b --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/receivers/StopAlarmReceiver.kt @@ -0,0 +1,22 @@ +package org.fossify.clock.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.fossify.clock.extensions.alarmController +import org.fossify.clock.extensions.goAsync +import org.fossify.clock.helpers.ALARM_ID + +/** + * Receiver responsible for stopping running alarms. + */ +class StopAlarmReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val id = intent.getIntExtra(ALARM_ID, -1) + if (id != -1) { + goAsync { + context.alarmController.stopAlarm(id) + } + } + } +} diff --git a/app/src/main/kotlin/org/fossify/clock/receivers/EarlyAlarmDismissalReceiver.kt b/app/src/main/kotlin/org/fossify/clock/receivers/UpcomingAlarmReceiver.kt similarity index 62% rename from app/src/main/kotlin/org/fossify/clock/receivers/EarlyAlarmDismissalReceiver.kt rename to app/src/main/kotlin/org/fossify/clock/receivers/UpcomingAlarmReceiver.kt index 6db52dab..b73c2c6e 100644 --- a/app/src/main/kotlin/org/fossify/clock/receivers/EarlyAlarmDismissalReceiver.kt +++ b/app/src/main/kotlin/org/fossify/clock/receivers/UpcomingAlarmReceiver.kt @@ -9,14 +9,20 @@ import android.content.Intent import androidx.core.app.NotificationCompat import org.fossify.clock.R import org.fossify.clock.extensions.getClosestEnabledAlarmString -import org.fossify.clock.extensions.getDismissAlarmPendingIntent import org.fossify.clock.extensions.getOpenAlarmTabIntent +import org.fossify.clock.extensions.getSkipUpcomingAlarmPendingIntent +import org.fossify.clock.extensions.goAsync import org.fossify.clock.helpers.ALARM_ID import org.fossify.clock.helpers.EARLY_ALARM_DISMISSAL_CHANNEL_ID -import org.fossify.clock.helpers.EARLY_ALARM_NOTIF_ID +import org.fossify.clock.helpers.UPCOMING_ALARM_NOTIFICATION_ID +import org.fossify.commons.extensions.notificationManager import org.fossify.commons.helpers.isOreoPlus -class EarlyAlarmDismissalReceiver : BroadcastReceiver() { +/** + * Receiver responsible for showing a notification that allows users to skip an upcoming alarm. + * This notification appears 10 minutes before (hardcoded) the alarm is scheduled to trigger. + */ +class UpcomingAlarmReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val alarmId = intent.getIntExtra(ALARM_ID, -1) @@ -24,12 +30,14 @@ class EarlyAlarmDismissalReceiver : BroadcastReceiver() { return } - triggerEarlyDismissalNotification(context, alarmId) + goAsync { + showUpcomingAlarmNotification(context, alarmId) + } } - private fun triggerEarlyDismissalNotification(context: Context, alarmId: Int) { + private fun showUpcomingAlarmNotification(context: Context, alarmId: Int) { context.getClosestEnabledAlarmString { alarmString -> - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = context.notificationManager if (isOreoPlus()) { NotificationChannel( EARLY_ALARM_DISMISSAL_CHANNEL_ID, @@ -41,22 +49,29 @@ class EarlyAlarmDismissalReceiver : BroadcastReceiver() { notificationManager.createNotificationChannel(this) } } - val dismissIntent = context.getDismissAlarmPendingIntent(alarmId, EARLY_ALARM_NOTIF_ID) + val contentIntent = context.getOpenAlarmTabIntent() + val dismissIntent = context.getSkipUpcomingAlarmPendingIntent( + alarmId = alarmId, notificationId = UPCOMING_ALARM_NOTIFICATION_ID + ) + val notification = NotificationCompat.Builder(context) .setContentTitle(context.getString(R.string.upcoming_alarm)) .setContentText(alarmString) .setSmallIcon(R.drawable.ic_alarm_vector) .setPriority(Notification.PRIORITY_LOW) - .addAction(0, context.getString(org.fossify.commons.R.string.dismiss), dismissIntent) + .addAction( + 0, + context.getString(org.fossify.commons.R.string.dismiss), + dismissIntent + ) .setContentIntent(contentIntent) .setSound(null) .setAutoCancel(true) .setChannelId(EARLY_ALARM_DISMISSAL_CHANNEL_ID) .build() - notificationManager.notify(EARLY_ALARM_NOTIF_ID, notification) + notificationManager.notify(UPCOMING_ALARM_NOTIFICATION_ID, notification) } } - } diff --git a/app/src/main/kotlin/org/fossify/clock/services/AlarmService.kt b/app/src/main/kotlin/org/fossify/clock/services/AlarmService.kt new file mode 100644 index 00000000..8f283fa3 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/clock/services/AlarmService.kt @@ -0,0 +1,229 @@ +package org.fossify.clock.services + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.app.Service +import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.AudioManager.STREAM_ALARM +import android.media.MediaPlayer +import android.os.Handler +import android.os.Looper +import android.os.VibrationEffect +import android.os.Vibrator +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import org.fossify.clock.R +import org.fossify.clock.activities.AlarmActivity +import org.fossify.clock.extensions.alarmController +import org.fossify.clock.extensions.config +import org.fossify.clock.extensions.dbHelper +import org.fossify.clock.extensions.getFormattedTime +import org.fossify.clock.extensions.getSnoozePendingIntent +import org.fossify.clock.extensions.getStopAlarmPendingIntent +import org.fossify.clock.helpers.ALARM_ID +import org.fossify.clock.helpers.ALARM_NOTIFICATION_CHANNEL_ID +import org.fossify.clock.helpers.ALARM_NOTIF_ID +import org.fossify.clock.models.Alarm +import org.fossify.commons.extensions.notificationManager +import org.fossify.commons.helpers.SILENT +import org.fossify.commons.helpers.isOreoPlus +import kotlin.time.Duration.Companion.seconds + +/** + * Service responsible for sounding the alarms and vibrations. + * It also shows a notification with actions to dismiss or snooze an alarm. + * Totally based on the previous implementation in the [AlarmActivity]. + */ +class AlarmService : Service() { + + companion object { + private const val DEFAULT_ALARM_VOLUME = 7 + private const val INCREASE_VOLUME_DELAY = 300L + private const val MIN_ALARM_VOLUME_FOR_INCREASING_ALARMS = 1 + } + + private var alarm: Alarm? = null + private var audioManager: AudioManager? = null + private var initialAlarmVolume = DEFAULT_ALARM_VOLUME + private var mediaPlayer: MediaPlayer? = null + private var vibrator: Vibrator? = null + + private val autoDismissHandler = Handler(Looper.getMainLooper()) + private val increaseVolumeHandler = Handler(Looper.getMainLooper()) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val alarmId = intent?.getIntExtra(ALARM_ID, -1) ?: -1 + alarm = if (alarmId != -1) { + applicationContext.dbHelper.getAlarmWithId(alarmId) + } else { + null + } + + if (alarm == null) { + stopSelf() + return START_NOT_STICKY + } + + val notification = buildNotification(alarm!!) + startForeground(ALARM_NOTIF_ID, notification) + startAlarmEffects(alarm!!) + startAutoDismiss(config.alarmMaxReminderSecs) + return START_STICKY + } + + private fun buildNotification(alarm: Alarm): Notification { + val channelId = ALARM_NOTIFICATION_CHANNEL_ID + if (isOreoPlus()) { + val channel = NotificationChannel( + channelId, + getString(org.fossify.commons.R.string.alarm), + NotificationManager.IMPORTANCE_HIGH + ).apply { + setBypassDnd(true) + setSound(null, null) + } + + notificationManager.createNotificationChannel(channel) + } + + val contentTitle = alarm.label.ifEmpty { + getString(org.fossify.commons.R.string.alarm) + } + + val contentText = getFormattedTime( + passedSeconds = alarm.timeInMinutes * 60, + showSeconds = false, + makeAmPmSmaller = false + ) + + val reminderIntent = Intent(this, AlarmActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(ALARM_ID, alarm.id) + } + + val pendingIntent = PendingIntent.getActivity( + this, 0, reminderIntent, FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE + ) + + val dismissIntent = applicationContext.getStopAlarmPendingIntent(alarm) + val snoozeIntent = applicationContext.getSnoozePendingIntent(alarm) + + return NotificationCompat.Builder(this, channelId) + .setContentTitle(contentTitle) + .setContentText(contentText) + .setSmallIcon(R.drawable.ic_alarm_vector) + .setContentIntent(pendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_ALARM) + .setDefaults(NotificationCompat.DEFAULT_LIGHTS) + .addAction( + org.fossify.commons.R.drawable.ic_snooze_vector, + getString(org.fossify.commons.R.string.snooze), + snoozeIntent + ) + .addAction( + org.fossify.commons.R.drawable.ic_cross_vector, + getString(org.fossify.commons.R.string.dismiss), + dismissIntent + ) + .setDeleteIntent(dismissIntent) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setFullScreenIntent(pendingIntent, true) + .build() + } + + private fun startAlarmEffects(alarm: Alarm) { + if (alarm.soundUri != SILENT) { + try { + val audioAttributes = AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setFlags(AudioAttributes.FLAG_AUDIBILITY_ENFORCED) + .build() + + mediaPlayer = MediaPlayer().apply { + setAudioAttributes(audioAttributes) + setDataSource(this@AlarmService, alarm.soundUri.toUri()) + isLooping = true + prepare() + start() + } + + if (config.increaseVolumeGradually) { + initialAlarmVolume = audioManager?.getStreamVolume(STREAM_ALARM) + ?: DEFAULT_ALARM_VOLUME + + scheduleVolumeIncrease( + lastVolume = MIN_ALARM_VOLUME_FOR_INCREASING_ALARMS.toFloat(), + maxVolume = initialAlarmVolume.toFloat(), + delay = 0 + ) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + if (alarm.vibrate && isOreoPlus()) { + vibrator = getSystemService(VIBRATOR_SERVICE) as Vibrator + val timing = 500L + val repeatIndex = 0 + vibrator?.vibrate( + VibrationEffect.createWaveform( + longArrayOf(timing, timing), repeatIndex + ) + ) + } + } + + private fun scheduleVolumeIncrease(lastVolume: Float, maxVolume: Float, delay: Long) { + increaseVolumeHandler.postDelayed({ + val volumeFlags = 0 + val newVolume = (lastVolume + 0.1f).coerceAtMost(maxVolume) + audioManager?.setStreamVolume(STREAM_ALARM, newVolume.toInt(), volumeFlags) + if (newVolume < maxVolume) { + scheduleVolumeIncrease(newVolume, maxVolume, INCREASE_VOLUME_DELAY) + } + }, delay) + } + + private fun resetVolumeToInitialValue() { + if (config.increaseVolumeGradually) { + val volumeFlags = 0 + audioManager?.setStreamVolume(STREAM_ALARM, initialAlarmVolume, volumeFlags) + } + } + + private fun startAutoDismiss(durationSecs: Int) { + val alarmId = alarm?.id ?: return + autoDismissHandler.postDelayed({ + alarmController.stopAlarm(alarmId) + }, durationSecs.seconds.inWholeMilliseconds) + } + + @SuppressLint("InlinedApi") + override fun onDestroy() { + super.onDestroy() + stopForeground(STOP_FOREGROUND_REMOVE) + + mediaPlayer?.stop() + mediaPlayer?.release() + mediaPlayer = null + vibrator?.cancel() + vibrator = null + + // Clear any scheduled volume changes or auto-dismiss messages + increaseVolumeHandler.removeCallbacksAndMessages(null) + autoDismissHandler.removeCallbacksAndMessages(null) + resetVolumeToInitialValue() + } + + override fun onBind(intent: Intent?) = null +} diff --git a/app/src/main/kotlin/org/fossify/clock/services/SnoozeService.kt b/app/src/main/kotlin/org/fossify/clock/services/SnoozeService.kt index 68f3c473..367b63f2 100644 --- a/app/src/main/kotlin/org/fossify/clock/services/SnoozeService.kt +++ b/app/src/main/kotlin/org/fossify/clock/services/SnoozeService.kt @@ -2,23 +2,13 @@ package org.fossify.clock.services import android.app.IntentService import android.content.Intent +import org.fossify.clock.extensions.alarmController import org.fossify.clock.extensions.config -import org.fossify.clock.extensions.dbHelper -import org.fossify.clock.extensions.hideNotification -import org.fossify.clock.extensions.setupAlarmClock import org.fossify.clock.helpers.ALARM_ID -import java.util.Calendar class SnoozeService : IntentService("Snooze") { override fun onHandleIntent(intent: Intent?) { val id = intent!!.getIntExtra(ALARM_ID, -1) - val alarm = dbHelper.getAlarmWithId(id) ?: return - hideNotification(id) - setupAlarmClock( - alarm = alarm, - triggerTimeMillis = Calendar.getInstance() - .apply { add(Calendar.MINUTE, config.snoozeTime) } - .timeInMillis - ) + alarmController.snoozeAlarm(id, config.snoozeTime) } } diff --git a/app/src/main/kotlin/org/fossify/clock/services/StopwatchService.kt b/app/src/main/kotlin/org/fossify/clock/services/StopwatchService.kt index 3267614e..5c422703 100644 --- a/app/src/main/kotlin/org/fossify/clock/services/StopwatchService.kt +++ b/app/src/main/kotlin/org/fossify/clock/services/StopwatchService.kt @@ -18,6 +18,7 @@ import org.fossify.clock.helpers.STOPWATCH_RUNNING_NOTIF_ID import org.fossify.clock.helpers.Stopwatch import org.fossify.clock.helpers.Stopwatch.State import org.fossify.clock.helpers.Stopwatch.UpdateListener +import org.fossify.commons.extensions.notificationManager import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.helpers.isOreoPlus import org.greenrobot.eventbus.EventBus @@ -26,14 +27,12 @@ import org.greenrobot.eventbus.ThreadMode class StopwatchService : Service() { private val bus = EventBus.getDefault() - private lateinit var notificationManager: NotificationManager private lateinit var notificationBuilder: NotificationCompat.Builder private var isStopping = false override fun onCreate() { super.onCreate() bus.register(this) - notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationBuilder = getServiceNotificationBuilder( getString(R.string.app_name), getString(R.string.stopwatch) @@ -71,8 +70,8 @@ class StopwatchService : Service() { ): NotificationCompat.Builder { val channelId = "simple_alarm_stopwatch" val label = getString(R.string.stopwatch) - val importance = NotificationManager.IMPORTANCE_DEFAULT if (isOreoPlus()) { + val importance = NotificationManager.IMPORTANCE_DEFAULT NotificationChannel(channelId, label, importance).apply { setSound(null, null) notificationManager.createNotificationChannel(this) @@ -93,7 +92,8 @@ class StopwatchService : Service() { private fun updateNotification(totalTime: Long) { val formattedDuration = totalTime.getFormattedDuration() - notificationBuilder.setContentTitle(formattedDuration).setContentText(getString(R.string.stopwatch)) + notificationBuilder.setContentTitle(formattedDuration) + .setContentText(getString(R.string.stopwatch)) notificationManager.notify(STOPWATCH_RUNNING_NOTIF_ID, notificationBuilder.build()) } @@ -127,7 +127,10 @@ class StopwatchService : Service() { fun startStopwatchService(context: Context) { Handler(Looper.getMainLooper()).post { try { - ContextCompat.startForegroundService(context, Intent(context, StopwatchService::class.java)) + ContextCompat.startForegroundService( + context, + Intent(context, StopwatchService::class.java) + ) } catch (e: Exception) { context.showErrorToast(e) } diff --git a/app/src/main/kotlin/org/fossify/clock/services/TimerService.kt b/app/src/main/kotlin/org/fossify/clock/services/TimerService.kt index 18afb4ac..416ae065 100644 --- a/app/src/main/kotlin/org/fossify/clock/services/TimerService.kt +++ b/app/src/main/kotlin/org/fossify/clock/services/TimerService.kt @@ -19,6 +19,7 @@ import org.fossify.clock.helpers.INVALID_TIMER_ID import org.fossify.clock.helpers.TIMER_RUNNING_NOTIF_ID import org.fossify.clock.models.TimerEvent import org.fossify.clock.models.TimerState +import org.fossify.commons.extensions.notificationManager import org.fossify.commons.extensions.showErrorToast import org.fossify.commons.helpers.isOreoPlus import org.greenrobot.eventbus.EventBus @@ -40,7 +41,14 @@ class TimerService : Service() { super.onStartCommand(intent, flags, startId) isStopping = false updateNotification() - startForeground(TIMER_RUNNING_NOTIF_ID, notification(getString(R.string.app_name), getString(R.string.timers_notification_msg), INVALID_TIMER_ID)) + startForeground( + TIMER_RUNNING_NOTIF_ID, + notification( + title = getString(R.string.app_name), + contentText = getString(R.string.timers_notification_msg), + firstRunningTimerId = INVALID_TIMER_ID + ) + ) return START_NOT_STICKY } @@ -49,15 +57,31 @@ class TimerService : Service() { val runningTimers = timers.filter { it.state is TimerState.Running } if (runningTimers.isNotEmpty()) { val firstTimer = runningTimers.first() - val formattedDuration = (firstTimer.state as TimerState.Running).tick.getFormattedDuration() + val formattedDuration = + (firstTimer.state as TimerState.Running).tick.getFormattedDuration() val contextText = when { - firstTimer.label.isNotEmpty() -> getString(R.string.timer_single_notification_label_msg, firstTimer.label) - else -> resources.getQuantityString(R.plurals.timer_notification_msg, runningTimers.size, runningTimers.size) + firstTimer.label.isNotEmpty() -> getString( + R.string.timer_single_notification_label_msg, + firstTimer.label + ) + + else -> resources.getQuantityString( + R.plurals.timer_notification_msg, + runningTimers.size, + runningTimers.size + ) } Handler(Looper.getMainLooper()).post { try { - startForeground(TIMER_RUNNING_NOTIF_ID, notification(formattedDuration, contextText, firstTimer.id!!)) + startForeground( + TIMER_RUNNING_NOTIF_ID, + notification( + title = formattedDuration, + contentText = contextText, + firstRunningTimerId = firstTimer.id!! + ) + ) } catch (e: Exception) { showErrorToast(e) } @@ -94,10 +118,13 @@ class TimerService : Service() { bus.unregister(this) } - private fun notification(title: String, contentText: String, firstRunningTimerId: Int): Notification { + private fun notification( + title: String, + contentText: String, + firstRunningTimerId: Int, + ): Notification { val channelId = "simple_alarm_timer" val label = getString(R.string.timer) - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (isOreoPlus()) { val importance = NotificationManager.IMPORTANCE_DEFAULT NotificationChannel(channelId, label, importance).apply { diff --git a/app/src/main/res/layout/activity_reminder.xml b/app/src/main/res/layout/activity_alarm.xml similarity index 89% rename from app/src/main/res/layout/activity_reminder.xml rename to app/src/main/res/layout/activity_alarm.xml index 95403643..ee3191cf 100644 --- a/app/src/main/res/layout/activity_reminder.xml +++ b/app/src/main/res/layout/activity_alarm.xml @@ -16,7 +16,7 @@ app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:text="@string/time_expired" /> + tools:text="Wake up!" /> + tools:text="05:00 AM" /> - -