diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index d6d6405d1..8f51ebc88 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -54,9 +54,25 @@ jobs: outputs: build_datetime: ${{ steps.set_date.outputs.build_datetime }} + safety_checks: + name: Safety Checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 1 + sparse-checkout: | + android/app/src/main/res/menu/main.xml + scripts/check_dev_page_hidden.sh + + - name: Ensure dev page is not exposed + run: | + chmod +x ./scripts/check_dev_page_hidden.sh + ./scripts/check_dev_page_hidden.sh + build: name: Build Android App - needs: set_build_datetime + needs: [set_build_datetime, safety_checks] runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c76421324..f3df42849 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,11 @@ jobs: ssh-key: ${{ secrets.VERSION_BUMP_DEPLOY_PRIVATE_KEY }} fetch-depth: 0 + - name: Ensure dev page is not exposed + run: | + chmod +x ./scripts/check_dev_page_hidden.sh + ./scripts/check_dev_page_hidden.sh + - name: Setup Node.js uses: actions/setup-node@v4 with: diff --git a/android/app/build.gradle b/android/app/build.gradle index 3864dab45..1ff219591 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -33,6 +33,14 @@ def isGitHubActions = System.getenv("GITHUB_ACTIONS") != null && System.getenv(" // so now we just do it ci def isCIBuild = isCiBuild || isGitHubActions +// Read dev page flag from local.properties (gitignored = can't leak to releases) +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localProperties.load(new FileInputStream(localPropertiesFile)) +} +def devPageEnabled = localProperties.getProperty('devPage.enabled', 'false').toBoolean() + android { compileSdkVersion rootProject.ext.compileSdkVersion @@ -94,6 +102,7 @@ android { // React Native New Architecture flags buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", project.hasProperty("newArchEnabled") ? project.property("newArchEnabled") : "false" buildConfigField "boolean", "IS_HERMES_ENABLED", project.hasProperty("hermesEnabled") ? project.property("hermesEnabled") : "true" + buildConfigField "boolean", "DEV_PAGE_ENABLED", devPageEnabled.toString() // Enhanced test instrumentation with JaCoCo and Ultron/Allure testInstrumentationRunner "com.atiurin.ultron.allure.UltronAllureTestRunner" diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt index f37887c5e..53deb0ff2 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt @@ -170,5 +170,21 @@ class SettingsTest { DevLog.info(LOG_TAG, "Running testFormatSnoozePresetSeconds") assertEquals("45s", PreferenceUtils.formatSnoozePreset(45 * 1000L)) } + + // === Display Next Alert Settings Tests === + + @Test + fun testDisplayNextGCalReminderDefaultValue() { + DevLog.info(LOG_TAG, "Running testDisplayNextGCalReminderDefaultValue") + // The default value should be true + assertTrue("displayNextGCalReminder should default to true", settings.displayNextGCalReminder) + } + + @Test + fun testDisplayNextAppAlertDefaultValue() { + DevLog.info(LOG_TAG, "Running testDisplayNextAppAlertDefaultValue") + // The default value should be false + assertFalse("displayNextAppAlert should default to false", settings.displayNextAppAlert) + } } diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt index cc8b2503c..2bc3ebfdd 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt @@ -1,16 +1,25 @@ package com.github.quarck.calnotify.textutils import android.content.Context +import android.preference.PreferenceManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.github.quarck.calnotify.Consts import com.github.quarck.calnotify.calendar.AttendanceStatus +import com.github.quarck.calnotify.calendar.CalendarEventDetails +import com.github.quarck.calnotify.calendar.CalendarProvider import com.github.quarck.calnotify.calendar.EventAlertRecord import com.github.quarck.calnotify.calendar.EventDisplayStatus import com.github.quarck.calnotify.calendar.EventOrigin +import com.github.quarck.calnotify.calendar.EventRecord +import com.github.quarck.calnotify.calendar.EventReminderRecord import com.github.quarck.calnotify.calendar.EventStatus import com.github.quarck.calnotify.logs.DevLog import com.github.quarck.calnotify.utils.CNPlusTestClock +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -39,6 +48,23 @@ class EventFormatterTest { DevLog.info(LOG_TAG, "Setup complete with baseTime=$baseTime") } + @After + fun cleanup() { + DevLog.info(LOG_TAG, "Cleaning up after test") + // Reset the next alert settings to defaults + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .remove("pref_display_next_gcal_reminder") + .remove("pref_display_next_app_alert") + .commit() + // Unmock CalendarProvider if it was mocked + try { + unmockkObject(CalendarProvider) + } catch (e: Exception) { + // Ignore if not mocked + } + } + private fun createTestEvent( eventId: Long = 1L, title: String = "Test Event", @@ -265,5 +291,128 @@ class EventFormatterTest { assertNotEquals("Different times should produce different timestamps", result1, result2) } + + // === Next Alert Indicator feature tests === + + private fun setNextGCalReminderSetting(enabled: Boolean) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean("pref_display_next_gcal_reminder", enabled) + .commit() + } + + private fun setNextAppAlertSetting(enabled: Boolean) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean("pref_display_next_app_alert", enabled) + .commit() + } + + private fun createMockEventRecord( + eventId: Long, + startTime: Long, + reminders: List + ): EventRecord { + return EventRecord( + calendarId = 1L, + eventId = eventId, + details = CalendarEventDetails( + title = "Test Event", + desc = "Test Description", + location = "", + timezone = "UTC", + startTime = startTime, + endTime = startTime + Consts.HOUR_IN_MILLISECONDS, + isAllDay = false, + reminders = reminders, + repeatingRule = "", + repeatingRDate = "", + repeatingExRule = "", + repeatingExRDate = "", + color = 0 + ), + eventStatus = EventStatus.Confirmed, + attendanceStatus = AttendanceStatus.None + ) + } + + @Test + fun testFormatNotificationSecondaryTextNextAlertTimeDisabled() { + DevLog.info(LOG_TAG, "Running testFormatNotificationSecondaryTextNextAlertTimeDisabled") + + // Ensure the setting is disabled (default) + setNextGCalReminderSetting(false) + + // Create a new formatter to pick up the setting + val testFormatter = EventFormatter(context, testClock) + + // Event starts 2 hours from now with a reminder 30 minutes before + val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS + val event = createTestEvent( + eventId = 100L, + startTime = eventStartTime, + endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS + ) + + val result = testFormatter.formatNotificationSecondaryText(event) + + DevLog.info(LOG_TAG, "Result with setting disabled: $result") + + // Should NOT contain "reminder in" when setting is disabled + assertFalse( + "Should NOT contain 'reminder in' when setting is disabled", + result.contains("reminder in", ignoreCase = true) + ) + } + + @Test + fun testFormatNotificationSecondaryTextNextGCalEnabled() { + DevLog.info(LOG_TAG, "Running testFormatNotificationSecondaryTextNextGCalEnabled") + + // Enable GCal setting, disable app alert + setNextGCalReminderSetting(true) + setNextAppAlertSetting(false) + + // Event starts 2 hours from now + val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS + val eventId = 101L + + // Create reminders: one that fires in the future (60min before = baseTime + 1h) + val reminders = listOf( + EventReminderRecord.minutes(60), // fires at baseTime + 1h (future from baseTime) + EventReminderRecord.minutes(30) // fires at baseTime + 1.5h (future from baseTime) + ) + + // Mock CalendarProvider to return our event with reminders + mockkObject(CalendarProvider) + every { CalendarProvider.getEvent(any(), eq(eventId)) } returns createMockEventRecord( + eventId = eventId, + startTime = eventStartTime, + reminders = reminders + ) + + // Create the test event (EventAlertRecord) + val event = createTestEvent( + eventId = eventId, + startTime = eventStartTime, + endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS + ) + + // Create a new formatter to pick up the setting + val testFormatter = EventFormatter(context, testClock) + + val result = testFormatter.formatNotificationSecondaryText(event) + + DevLog.info(LOG_TAG, "Result with setting enabled: $result") + DevLog.info(LOG_TAG, "Event start time: $eventStartTime, Base time: $baseTime") + DevLog.info(LOG_TAG, "Reminder 1 fires at: ${eventStartTime - 60 * Consts.MINUTE_IN_MILLISECONDS}") + DevLog.info(LOG_TAG, "Reminder 2 fires at: ${eventStartTime - 30 * Consts.MINUTE_IN_MILLISECONDS}") + + // Should contain πŸ“… when GCal setting is enabled and there are future reminders + assertTrue( + "Should contain πŸ“… when setting is enabled and future GCal reminders exist. Result: $result", + result.contains("πŸ“…") + ) + } } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt index f10645504..1e34a23b8 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt @@ -137,6 +137,14 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter val snoozePresetsRaw: String get() = getString(SNOOZE_PRESET_KEY, DEFAULT_SNOOZE_PRESET) + var displayNextGCalReminder: Boolean + get() = getBoolean(DISPLAY_NEXT_GCAL_REMINDER, true) + set(value) = setBoolean(DISPLAY_NEXT_GCAL_REMINDER, value) + + var displayNextAppAlert: Boolean + get() = getBoolean(DISPLAY_NEXT_APP_ALERT, false) + set(value) = setBoolean(DISPLAY_NEXT_APP_ALERT, value) + val snoozePresets: LongArray get() { var ret = PreferenceUtils.parseSnoozePresets(snoozePresetsRaw) @@ -443,6 +451,8 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter private const val CALENDAR_IS_HANDLED_KEY_PREFIX = "calendar_handled_" private const val SNOOZE_PRESET_KEY = "pref_snooze_presets" //"15m, 1h, 4h, 1d" + private const val DISPLAY_NEXT_GCAL_REMINDER = "pref_display_next_gcal_reminder" // true + private const val DISPLAY_NEXT_APP_ALERT = "pref_display_next_app_alert" // false private const val VIEW_AFTER_EDIT_KEY = "show_event_after_reschedule" // true private const val ENABLE_REMINDERS_KEY = "enable_reminding_key" // false private const val REMINDER_INTERVAL_PATTERN_KEY = "remind_interval_key_pattern" // "10m" diff --git a/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt b/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt index 7e6c9461c..61f106d38 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt @@ -167,4 +167,12 @@ fun EventRecord.nextAlarmTime(currentTime: Long): Long { } return ret -} \ No newline at end of file +} + +fun EventRecord.getNextAlertTimeAfter(anchor: Long): Long? { + val futureReminders = this + .reminders + .map { this.startTime - it.millisecondsBefore } + .filter { it > anchor } + return futureReminders.maxOrNull() +} diff --git a/android/app/src/main/java/com/github/quarck/calnotify/notification/EventNotificationManager.kt b/android/app/src/main/java/com/github/quarck/calnotify/notification/EventNotificationManager.kt index a3159fa11..da98b0192 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/notification/EventNotificationManager.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/notification/EventNotificationManager.kt @@ -507,9 +507,19 @@ open class EventNotificationManager : EventNotificationManagerInterface { val numEvents = events.size - val title = java.lang.String.format( + val baseTitle = java.lang.String.format( context.getString(R.string.multiple_events_single_notification), numEvents) + + // Add next notification indicator to title if enabled + val formatter = EventFormatter(context, clock) + val nextIndicator = formatter.formatNextNotificationIndicatorForCollapsed( + events = events, + displayNextGCalReminder = settings.displayNextGCalReminder, + displayNextAppAlert = settings.displayNextAppAlert, + remindersEnabled = settings.remindersEnabled + ) + val title = if (nextIndicator != null) "$baseTitle $nextIndicator" else baseTitle val text = context.getString(R.string.multiple_events_details_2) @@ -1471,7 +1481,17 @@ open class EventNotificationManager : EventNotificationManagerInterface { val numEvents = events.size - val title = java.lang.String.format(context.getString(R.string.multiple_events), numEvents) + val baseTitle = java.lang.String.format(context.getString(R.string.multiple_events), numEvents) + + // Add next notification indicator to title if enabled + val formatter = EventFormatter(context, clock) + val nextIndicator = formatter.formatNextNotificationIndicatorForCollapsed( + events = events, + displayNextGCalReminder = settings.displayNextGCalReminder, + displayNextAppAlert = settings.displayNextAppAlert, + remindersEnabled = settings.remindersEnabled + ) + val title = if (nextIndicator != null) "$baseTitle $nextIndicator" else baseTitle val text = context.getString(com.github.quarck.calnotify.R.string.multiple_events_details_2) diff --git a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt index 15e7460eb..e7b2e6b58 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt @@ -22,15 +22,38 @@ package com.github.quarck.calnotify.textutils import android.content.Context import android.text.format.DateUtils import com.github.quarck.calnotify.Consts +import com.github.quarck.calnotify.Settings import com.github.quarck.calnotify.R import com.github.quarck.calnotify.calendar.EventAlertRecord import com.github.quarck.calnotify.calendar.displayedEndTime import com.github.quarck.calnotify.calendar.displayedStartTime +import com.github.quarck.calnotify.calendar.CalendarProviderInterface +import com.github.quarck.calnotify.calendar.CalendarProvider +import com.github.quarck.calnotify.calendar.getNextAlertTimeAfter +import com.github.quarck.calnotify.reminders.ReminderState +import com.github.quarck.calnotify.reminders.ReminderStateInterface import com.github.quarck.calnotify.utils.DateTimeUtils import com.github.quarck.calnotify.utils.CNPlusClockInterface import com.github.quarck.calnotify.utils.CNPlusSystemClock import java.util.* +/** + * Type of next notification + */ +enum class NextNotificationType { + GCAL_REMINDER, + APP_ALERT +} + +/** + * Result of calculating the next notification info + */ +data class NextNotificationInfo( + val type: NextNotificationType, + val timeUntilMillis: Long, + val isMuted: Boolean +) + fun dateToStr(ctx: Context, time: Long) = DateUtils.formatDateTime(ctx, time, DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE) @@ -59,7 +82,9 @@ fun encodedMinuteTimestamp(modulo: Long = 60 * 24 * 30L, clock: CNPlusClockInter class EventFormatter( val ctx: Context, - private val clock: CNPlusClockInterface = CNPlusSystemClock() + private val clock: CNPlusClockInterface = CNPlusSystemClock(), + private val calendarProvider: CalendarProviderInterface = CalendarProvider, + private val reminderStateProvider: () -> ReminderStateInterface = { ReminderState(ctx) } ) : EventFormatterInterface { private val defaultLocale by lazy { Locale.getDefault() } @@ -79,6 +104,18 @@ class EventFormatter( sb.append(formatDateTimeOneLine(event, false)) + val settings = Settings(ctx) + val nextNotificationDisplay = formatNextNotificationIndicator( + event = event, + displayNextGCalReminder = settings.displayNextGCalReminder, + displayNextAppAlert = settings.displayNextAppAlert, + remindersEnabled = settings.remindersEnabled + ) + if (nextNotificationDisplay != null) { + sb.append(" ") + sb.append(nextNotificationDisplay) + } + if (event.location != "") { sb.append("\n") sb.append(ctx.resources.getString(R.string.location)); @@ -89,6 +126,125 @@ class EventFormatter( return sb.toString() } + /** + * Formats the next notification indicator for a single event. + * Returns null if no indicator should be shown. + */ + fun formatNextNotificationIndicator( + event: EventAlertRecord, + displayNextGCalReminder: Boolean, + displayNextAppAlert: Boolean, + remindersEnabled: Boolean + ): String? { + val currentTime = clock.currentTimeMillis() + + val nextInfo = calculateNextNotificationInfo( + event = event, + currentTime = currentTime, + displayNextGCalReminder = displayNextGCalReminder, + displayNextAppAlert = displayNextAppAlert, + remindersEnabled = remindersEnabled + ) ?: return null + + return formatNextNotificationInfo(nextInfo) + } + + /** + * Calculates what the next notification will be for a single event. + */ + fun calculateNextNotificationInfo( + event: EventAlertRecord, + currentTime: Long, + displayNextGCalReminder: Boolean, + displayNextAppAlert: Boolean, + remindersEnabled: Boolean + ): NextNotificationInfo? { + // Get next GCal reminder if enabled + val nextGCalTime: Long? = if (displayNextGCalReminder) { + val eventRecord = calendarProvider.getEvent(ctx, event.eventId) + eventRecord?.getNextAlertTimeAfter(currentTime) + } else null + + // Get next app alert if enabled (show for muted events too - they still get alerts on silent channel) + val nextAppTime: Long? = if (displayNextAppAlert && remindersEnabled) { + val reminderState = reminderStateProvider() + val nextFire = reminderState.nextFireExpectedAt + if (nextFire > currentTime) nextFire else null + } else null + + return calculateNextNotification( + nextGCalTime = nextGCalTime, + nextAppTime = nextAppTime, + currentTime = currentTime, + isMuted = event.isMuted + ) + } + + /** + * Formats the next notification indicator for collapsed notifications. + * Finds the soonest notification across all events. + */ + fun formatNextNotificationIndicatorForCollapsed( + events: List, + displayNextGCalReminder: Boolean, + displayNextAppAlert: Boolean, + remindersEnabled: Boolean + ): String? { + val currentTime = clock.currentTimeMillis() + + // Find soonest GCal reminder across all events + val soonestGCalTime: Long? = if (displayNextGCalReminder) { + events.mapNotNull { event -> + calendarProvider.getEvent(ctx, event.eventId)?.getNextAlertTimeAfter(currentTime) + }.minOrNull() + } else null + + // App alert time is the same for all events + val nextAppTime: Long? = if (displayNextAppAlert && remindersEnabled) { + // For collapsed, check if ANY event is unmuted (app alerts only fire for unmuted) + val anyUnmuted = events.any { !it.isMuted } + if (anyUnmuted) { + val reminderState = reminderStateProvider() + val nextFire = reminderState.nextFireExpectedAt + if (nextFire > currentTime) nextFire else null + } else null + } else null + + // For collapsed, consider muted if ALL events are muted + val allMuted = events.all { it.isMuted } + + val nextInfo = calculateNextNotification( + nextGCalTime = soonestGCalTime, + nextAppTime = nextAppTime, + currentTime = currentTime, + isMuted = allMuted + ) ?: return null + + return formatNextNotificationInfo(nextInfo) + } + + /** + * Formats a NextNotificationInfo into a display string. + */ + private fun formatNextNotificationInfo(info: NextNotificationInfo): String { + // Round to nearest minute and format with compound units (e.g., "6h 56m") + val roundedMillis = roundToNearestMinute(info.timeUntilMillis) + val timeStr = formatDurationCompact(roundedMillis) + + val indicatorStr = when (info.type) { + NextNotificationType.GCAL_REMINDER -> ctx.getString(R.string.next_gcal_indicator, timeStr) + NextNotificationType.APP_ALERT -> ctx.getString(R.string.next_app_indicator, timeStr) + } + + // Add muted prefix and wrap in parentheses + // Note: muted_prefix already includes trailing space + return if (info.isMuted) { + "(${ctx.getString(R.string.muted_prefix)} $indicatorStr)" + } else { + "($indicatorStr)" + } + } + override fun formatDateTimeTwoLines(event: EventAlertRecord, showWeekDay: Boolean): Pair = when { event.isAllDay -> @@ -396,4 +552,80 @@ class EventFormatter( return "$num $unit" } + companion object { + private const val MINUTE_MS = 60 * 1000L + private const val HOUR_MS = 60 * MINUTE_MS + private const val DAY_MS = 24 * HOUR_MS + + /** + * Rounds milliseconds to the nearest minute for cleaner display. + * Minimum of 1 minute to avoid showing "0m" or "now". + */ + fun roundToNearestMinute(millis: Long): Long { + val rounded = ((millis + MINUTE_MS / 2) / MINUTE_MS) * MINUTE_MS + return maxOf(rounded, MINUTE_MS) // At least 1 minute + } + + /** + * Formats a duration in milliseconds into a compact human-readable string. + * - < 1 hour: just minutes (e.g., "45m") + * - >= 1 hour, < 1 day: hours + minutes (e.g., "6h 56m") + * - >= 1 day: days + hours, no minutes (e.g., "2d 3h") + */ + fun formatDurationCompact(millis: Long): String { + val totalMinutes = millis / MINUTE_MS + val totalHours = millis / HOUR_MS + val totalDays = millis / DAY_MS + + return when { + totalDays >= 1 -> { + // Days + hours remainder (no minutes) + val remainingHours = (millis % DAY_MS) / HOUR_MS + if (remainingHours > 0) "${totalDays}d ${remainingHours}h" else "${totalDays}d" + } + totalHours >= 1 -> { + // Hours + minutes remainder + val remainingMinutes = (millis % HOUR_MS) / MINUTE_MS + if (remainingMinutes > 0) "${totalHours}h ${remainingMinutes}m" else "${totalHours}h" + } + else -> { + // Just minutes + "${maxOf(totalMinutes, 1)}m" + } + } + } + + /** + * Pure calculation function to determine the next notification. + * GCal wins ties. + * Returns null if neither time is available. + */ + fun calculateNextNotification( + nextGCalTime: Long?, + nextAppTime: Long?, + currentTime: Long, + isMuted: Boolean + ): NextNotificationInfo? { + val gcalDuration = nextGCalTime?.let { it - currentTime }?.takeIf { it > 0 } + val appDuration = nextAppTime?.let { it - currentTime }?.takeIf { it > 0 } + + return when { + gcalDuration != null && appDuration != null -> { + // Both available - GCal wins ties (<=) + if (gcalDuration <= appDuration) { + NextNotificationInfo(NextNotificationType.GCAL_REMINDER, gcalDuration, isMuted) + } else { + NextNotificationInfo(NextNotificationType.APP_ALERT, appDuration, isMuted) + } + } + gcalDuration != null -> { + NextNotificationInfo(NextNotificationType.GCAL_REMINDER, gcalDuration, isMuted) + } + appDuration != null -> { + NextNotificationInfo(NextNotificationType.APP_ALERT, appDuration, isMuted) + } + else -> null + } + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivity.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivity.kt index 8e57bd049..849890ffc 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivity.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/MainActivity.kt @@ -493,10 +493,9 @@ class MainActivity : AppCompatActivity(), EventListCallback { R.string.start_quiet_hours) } - if (settings.devModeEnabled) { - menu.findItem(R.id.action_test_page)?.isVisible = true -// menu.findItem(R.id.action_add_event)?.isVisible = true - } + // DEV_PAGE_ENABLED is set via local.properties (gitignored, can't leak to releases) + // devModeEnabled is the easter egg fallback (tap 13x in Report a Bug) + menu.findItem(R.id.action_test_page)?.isVisible = BuildConfig.DEV_PAGE_ENABLED || settings.devModeEnabled return true } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/TestActivity.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/TestActivity.kt index 3c3b81cbd..5b415a6c3 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/TestActivity.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/TestActivity.kt @@ -32,8 +32,16 @@ import com.github.quarck.calnotify.Consts import com.github.quarck.calnotify.R import com.github.quarck.calnotify.Settings import com.github.quarck.calnotify.app.ApplicationController +import com.github.quarck.calnotify.calendar.AttendanceStatus +import com.github.quarck.calnotify.calendar.CalendarEventDetails +import com.github.quarck.calnotify.calendar.CalendarProvider +import com.github.quarck.calnotify.calendar.EventAlertFlags import com.github.quarck.calnotify.calendar.EventAlertRecord import com.github.quarck.calnotify.calendar.EventDisplayStatus +import com.github.quarck.calnotify.calendar.EventOrigin +import com.github.quarck.calnotify.calendar.EventReminderRecord +import com.github.quarck.calnotify.calendar.EventStatus +import com.github.quarck.calnotify.logs.DevLog import com.github.quarck.calnotify.utils.CNPlusClockInterface import com.github.quarck.calnotify.utils.CNPlusSystemClock import com.github.quarck.calnotify.utils.findOrThrow @@ -224,6 +232,334 @@ class TestActivity : Activity() { ApplicationController.afterCalendarEventFired(this) } + @Suppress("unused", "UNUSED_PARAMETER") + fun OnButtonAddReminderInEventClick(v: View) { + val LOG_TAG = "TestActivity" + + // Enable the displayNextGCalReminder setting (default is true but be explicit) + settings.displayNextGCalReminder = true + settings.displayNextAppAlert = false + DevLog.info(LOG_TAG, "Enabled displayNextGCalReminder setting") + + // Find a calendar to use + val calendars = CalendarProvider.getCalendars(this) + val calendar = calendars.firstOrNull() + + if (calendar == null) { + DevLog.error(LOG_TAG, "No calendars available - cannot create test event") + return + } + + val currentTime = clock.currentTimeMillis() + // Event starts 2 hours from now + val eventStart = currentTime + 2 * Consts.HOUR_IN_MILLISECONDS + val eventEnd = eventStart + Consts.HOUR_IN_MILLISECONDS + + // Create reminders at 15min, 30min, and 60min before event + // These will be at 1h45m, 1h30m, and 1h from now respectively + val reminders = listOf( + EventReminderRecord.minutes(15), // fires 1h45m from now + EventReminderRecord.minutes(30), // fires 1h30m from now + EventReminderRecord.minutes(60) // fires 1h from now + ) + + val details = CalendarEventDetails( + title = "Test Reminder In Feature - ${System.currentTimeMillis() % 10000}", + desc = "This event tests the next notification indicator feature", + location = "", + timezone = java.util.TimeZone.getDefault().id, + startTime = eventStart, + endTime = eventEnd, + isAllDay = false, + reminders = reminders, + color = 0xff00aa00.toInt() + ) + + val eventId = CalendarProvider.createEvent(this, calendar.calendarId, calendar.owner, details) + + if (eventId == -1L) { + DevLog.error(LOG_TAG, "Failed to create calendar event") + return + } + + DevLog.info(LOG_TAG, "Created calendar event $eventId with ${reminders.size} reminders") + + // Create and post a notification for this event + val event = EventAlertRecord( + calendarId = calendar.calendarId, + eventId = eventId, + isAllDay = false, + isRepeating = false, + alertTime = currentTime, + notificationId = 0, + title = details.title, + desc = details.desc, + startTime = eventStart, + endTime = eventEnd, + instanceStartTime = eventStart, + instanceEndTime = eventEnd, + location = details.location, + lastStatusChangeTime = currentTime, + snoozedUntil = 0L, + displayStatus = EventDisplayStatus.Hidden, + color = details.color + ) + + ApplicationController.registerNewEvent(this, event) + ApplicationController.postEventNotifications(this, listOf(event)) + ApplicationController.afterCalendarEventFired(this) + + DevLog.info(LOG_TAG, "Posted notification - look for 'πŸ“… in X' in the notification text!") + } + + /** + * Test collapsed notifications with next alert indicator. + * Creates 10 events to trigger collapsed view (exceeds maxNotifications). + */ + @Suppress("unused", "UNUSED_PARAMETER") + fun OnButtonTestCollapsedNextAlertClick(v: View) { + val LOG_TAG = "TestActivity" + + // Enable settings + settings.displayNextGCalReminder = true + settings.displayNextAppAlert = false + // Note: collapseEverything is read-only, so we create enough events to naturally trigger collapse + DevLog.info(LOG_TAG, "Testing collapsed notifications with next alert indicator") + + val calendars = CalendarProvider.getCalendars(this) + val calendar = calendars.firstOrNull() + + if (calendar == null) { + DevLog.error(LOG_TAG, "No calendars available - cannot create test events") + return + } + + val currentTime = clock.currentTimeMillis() + val events = mutableListOf() + + // Create 10 events to exceed maxNotifications and trigger collapse + // Event 1: reminder in 30m (soonest) + // Events 2-10: various reminder times + val eventConfigs = listOf( + Triple("Event 1 (30m reminder)", 2 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(90))), // fires in 30m + Triple("Event 2 (1h reminder)", 3 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(120))), // fires in 1h + Triple("Event 3 (2h reminder)", 4 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(120))), // fires in 2h + Triple("Event 4 (3h reminder)", 5 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(120))), // fires in 3h + Triple("Event 5", 6 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(120))), + Triple("Event 6", 7 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(120))), + Triple("Event 7", 8 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(120))), + Triple("Event 8", 9 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(120))), + Triple("Event 9", 10 * Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(120))), + Triple("Event 10 (no future reminder)", -Consts.HOUR_IN_MILLISECONDS, listOf(EventReminderRecord.minutes(15))) // started 1h ago, reminder already fired + ) + + for ((title, startOffset, reminders) in eventConfigs) { + val eventStart = currentTime + startOffset + val eventEnd = eventStart + Consts.HOUR_IN_MILLISECONDS + + val details = CalendarEventDetails( + title = "$title - ${System.currentTimeMillis() % 10000}", + desc = "Collapsed notification test", + location = "", + timezone = java.util.TimeZone.getDefault().id, + startTime = eventStart, + endTime = eventEnd, + isAllDay = false, + reminders = reminders, + color = 0xff0066aa.toInt() + ) + + val eventId = CalendarProvider.createEvent(this, calendar.calendarId, calendar.owner, details) + + if (eventId != -1L) { + val event = EventAlertRecord( + calendarId = calendar.calendarId, + eventId = eventId, + isAllDay = false, + isRepeating = false, + alertTime = currentTime, + notificationId = 0, + title = details.title, + desc = details.desc, + startTime = eventStart, + endTime = eventEnd, + instanceStartTime = eventStart, + instanceEndTime = eventEnd, + location = details.location, + lastStatusChangeTime = currentTime, + snoozedUntil = 0L, + displayStatus = EventDisplayStatus.Hidden, + color = details.color + ) + events.add(event) + ApplicationController.registerNewEvent(this, event) + } + } + + ApplicationController.postEventNotifications(this, events) + ApplicationController.afterCalendarEventFired(this) + + DevLog.info(LOG_TAG, "Posted ${events.size} collapsed notifications - look for '(πŸ“… 30m)' in the title!") + } + + /** + * Test muted event with next alert indicator. + * Creates a muted event to show the πŸ”‡ prefix. + */ + @Suppress("unused", "UNUSED_PARAMETER") + fun OnButtonTestMutedNextAlertClick(v: View) { + val LOG_TAG = "TestActivity" + + settings.displayNextGCalReminder = true + settings.displayNextAppAlert = false + DevLog.info(LOG_TAG, "Testing muted event with next alert indicator") + + val calendars = CalendarProvider.getCalendars(this) + val calendar = calendars.firstOrNull() + + if (calendar == null) { + DevLog.error(LOG_TAG, "No calendars available - cannot create test event") + return + } + + val currentTime = clock.currentTimeMillis() + val eventStart = currentTime + 2 * Consts.HOUR_IN_MILLISECONDS + val eventEnd = eventStart + Consts.HOUR_IN_MILLISECONDS + + val reminders = listOf(EventReminderRecord.minutes(60)) // fires in 1h + + val details = CalendarEventDetails( + title = "Muted Test Event - ${System.currentTimeMillis() % 10000}", + desc = "This event is muted - should show πŸ”‡ πŸ“… in X", + location = "", + timezone = java.util.TimeZone.getDefault().id, + startTime = eventStart, + endTime = eventEnd, + isAllDay = false, + reminders = reminders, + color = 0xffaa0000.toInt() + ) + + val eventId = CalendarProvider.createEvent(this, calendar.calendarId, calendar.owner, details) + + if (eventId == -1L) { + DevLog.error(LOG_TAG, "Failed to create calendar event") + return + } + + // Create muted event + val event = EventAlertRecord( + calendarId = calendar.calendarId, + eventId = eventId, + isAllDay = false, + isRepeating = false, + alertTime = currentTime, + notificationId = 0, + title = details.title, + desc = details.desc, + startTime = eventStart, + endTime = eventEnd, + instanceStartTime = eventStart, + instanceEndTime = eventEnd, + location = details.location, + lastStatusChangeTime = currentTime, + snoozedUntil = 0L, + displayStatus = EventDisplayStatus.Hidden, + color = details.color, + origin = EventOrigin.ProviderBroadcast, + timeFirstSeen = currentTime, + eventStatus = EventStatus.Confirmed, + attendanceStatus = AttendanceStatus.None, + flags = EventAlertFlags.IS_MUTED + ) + + ApplicationController.registerNewEvent(this, event) + ApplicationController.postEventNotifications(this, listOf(event)) + ApplicationController.afterCalendarEventFired(this) + + DevLog.info(LOG_TAG, "Posted muted notification - look for 'πŸ”‡ πŸ“… in X' in the notification text!") + } + + /** + * Test app alert indicator (πŸ””). + * Creates an event with NO future GCal reminders so only the app alert shows. + * Requires reminders to be enabled in settings. + */ + @Suppress("unused", "UNUSED_PARAMETER") + fun OnButtonTestAppAlertClick(v: View) { + val LOG_TAG = "TestActivity" + + // Enable app alert, disable GCal reminder + settings.displayNextGCalReminder = false + settings.displayNextAppAlert = true + DevLog.info(LOG_TAG, "Testing app alert indicator (πŸ””)") + + if (!settings.remindersEnabled) { + DevLog.warn(LOG_TAG, "Reminders are disabled - enable them in settings to see app alert!") + } + + val calendars = CalendarProvider.getCalendars(this) + val calendar = calendars.firstOrNull() + + if (calendar == null) { + DevLog.error(LOG_TAG, "No calendars available - cannot create test event") + return + } + + val currentTime = clock.currentTimeMillis() + // Event started 1 hour ago (so no future GCal reminders) + val eventStart = currentTime - Consts.HOUR_IN_MILLISECONDS + val eventEnd = eventStart + Consts.HOUR_IN_MILLISECONDS + + // No reminders - the event has already started + val reminders = listOf() + + val details = CalendarEventDetails( + title = "App Alert Test - ${System.currentTimeMillis() % 10000}", + desc = "This event shows the app alert indicator (πŸ””). Reminders must be enabled!", + location = "", + timezone = java.util.TimeZone.getDefault().id, + startTime = eventStart, + endTime = eventEnd, + isAllDay = false, + reminders = reminders, + color = 0xff0066cc.toInt() + ) + + val eventId = CalendarProvider.createEvent(this, calendar.calendarId, calendar.owner, details) + + if (eventId == -1L) { + DevLog.error(LOG_TAG, "Failed to create calendar event") + return + } + + val event = EventAlertRecord( + calendarId = calendar.calendarId, + eventId = eventId, + isAllDay = false, + isRepeating = false, + alertTime = currentTime, + notificationId = 0, + title = details.title, + desc = details.desc, + startTime = eventStart, + endTime = eventEnd, + instanceStartTime = eventStart, + instanceEndTime = eventEnd, + location = details.location, + lastStatusChangeTime = currentTime, + snoozedUntil = 0L, + displayStatus = EventDisplayStatus.Hidden, + color = details.color + ) + + ApplicationController.registerNewEvent(this, event) + ApplicationController.postEventNotifications(this, listOf(event)) + ApplicationController.afterCalendarEventFired(this) + + DevLog.info(LOG_TAG, "Posted notification - look for '(πŸ”” Xm)' based on reminder interval!") + } + @Suppress("unused", "UNUSED_PARAMETER") fun OnButtonAddProvierEventClick(v: View) { diff --git a/android/app/src/main/res/layout/activity_test.xml b/android/app/src/main/res/layout/activity_test.xml index 87186fad1..0fec0daac 100644 --- a/android/app/src/main/res/layout/activity_test.xml +++ b/android/app/src/main/res/layout/activity_test.xml @@ -83,6 +83,42 @@ WARNING!\n\nNo usable hidden functionality can be found here\nThis Activity is p android:padding="14dp" /> +