Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ object Consts {
const val DAY_IN_MILLISECONDS = 24L * 3600L * 1000L
const val DAY_IN_SECONDS: Long = 3600L * 24
const val DAY_IN_MINUTES: Int = 60*24
const val WEEK_IN_SECONDS: Long = DAY_IN_SECONDS * 7
const val WEEK_IN_MILLISECONDS: Long = DAY_IN_MILLISECONDS * 7
const val HOUR_IN_SECONDS: Long = 3600L
const val HOUR_IN_MILLISECONDS: Long = 3600L * 1000L
const val MINUTE_IN_SECONDS: Long = 60L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -469,13 +469,38 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter
.coerceIn(MIN_DAY_BOUNDARY_HOUR, MAX_DAY_BOUNDARY_HOUR)
set(value) = setString(UPCOMING_EVENTS_DAY_BOUNDARY_HOUR_KEY, value.coerceIn(MIN_DAY_BOUNDARY_HOUR, MAX_DAY_BOUNDARY_HOUR).toString())

/** Fixed hours lookahead (1-48, default 8). Bounded to prevent misconfiguration. */
/** Fixed hours lookahead (1-48, default 8). Legacy property — prefer upcomingEventsFixedLookaheadMillis. */
var upcomingEventsFixedHours: Int
get() = (getString(UPCOMING_EVENTS_FIXED_HOURS_KEY, DEFAULT_UPCOMING_EVENTS_FIXED_HOURS.toString())
.toIntOrNull() ?: DEFAULT_UPCOMING_EVENTS_FIXED_HOURS)
.coerceIn(MIN_FIXED_HOURS, MAX_FIXED_HOURS)
set(value) = setString(UPCOMING_EVENTS_FIXED_HOURS_KEY, value.coerceIn(MIN_FIXED_HOURS, MAX_FIXED_HOURS).toString())

/** Fixed lookahead in milliseconds. Clamped to MAX_LOOKAHEAD_MILLIS (scan window).
* Falls back to legacy upcomingEventsFixedHours if no millis value is stored. */
var upcomingEventsFixedLookaheadMillis: Long
get() {
val raw = getLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, -1L)
if (raw > 0) return raw.coerceAtMost(MAX_LOOKAHEAD_MILLIS)
return (upcomingEventsFixedHours.toLong() * Consts.HOUR_IN_MILLISECONDS).coerceAtMost(MAX_LOOKAHEAD_MILLIS)
}
set(value) = setLong(UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY, value.coerceIn(1L, MAX_LOOKAHEAD_MILLIS))

/** Raw configurable upcoming time presets string (e.g., "4h, 8h, 1d, 3d, 1w") */
val upcomingTimePresetsRaw: String
get() = getString(UPCOMING_TIME_PRESETS_KEY, DEFAULT_UPCOMING_TIME_PRESETS)

/** Parsed upcoming time presets in milliseconds. Filters out negative and >30d values. */
val upcomingTimePresets: LongArray
get() {
val ret = PreferenceUtils.parseSnoozePresets(upcomingTimePresetsRaw)
?: PreferenceUtils.parseSnoozePresets(DEFAULT_UPCOMING_TIME_PRESETS)
?: return longArrayOf()
return ret.filter { it > 0 && it <= MAX_LOOKAHEAD_MILLIS }
.take(MAX_UPCOMING_TIME_PRESETS)
.toLongArray()
}

/** Max calendars to show in calendar filter. 0 = no limit (show all). */
val calendarFilterMaxItems: Int
get() = getString(CALENDAR_FILTER_MAX_ITEMS_KEY, DEFAULT_CALENDAR_FILTER_MAX_ITEMS.toString())
Expand Down Expand Up @@ -586,6 +611,8 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter
private const val UPCOMING_EVENTS_MODE_KEY = "upcoming_events_mode"
private const val UPCOMING_EVENTS_DAY_BOUNDARY_HOUR_KEY = "upcoming_events_day_boundary_hour"
private const val UPCOMING_EVENTS_FIXED_HOURS_KEY = "upcoming_events_fixed_hours"
private const val UPCOMING_FIXED_LOOKAHEAD_MILLIS_KEY = "upcoming_fixed_lookahead_millis"
private const val UPCOMING_TIME_PRESETS_KEY = "pref_upcoming_time_presets"
private const val CALENDAR_FILTER_MAX_ITEMS_KEY = "calendar_filter_max_items"
private const val CALENDAR_FILTER_SHOW_SEARCH_KEY = "calendar_filter_show_search"
private const val CALENDAR_FILTER_SHOW_IDS_KEY = "calendar_filter_show_ids"
Expand All @@ -607,6 +634,10 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter
internal const val MAX_DAY_BOUNDARY_HOUR = 10 // 10am (max slack for night owls)
internal const val MIN_FIXED_HOURS = 1
internal const val MAX_FIXED_HOURS = 48
internal const val DEFAULT_UPCOMING_TIME_PRESETS = "4h, 8h, 1d, 3d, 1w"
internal const val MAX_UPCOMING_TIME_PRESETS = 10
internal const val MAX_LOOKAHEAD_DAYS = 30L
internal const val MAX_LOOKAHEAD_MILLIS = MAX_LOOKAHEAD_DAYS * Consts.DAY_IN_MILLISECONDS
/** Default max calendars in filter (0 = no limit) */
internal const val DEFAULT_CALENDAR_FILTER_MAX_ITEMS = 20
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceDialogFragmentCompat
import com.github.quarck.calnotify.R
import com.github.quarck.calnotify.Settings
import com.github.quarck.calnotify.ui.MainActivityLegacy
Expand All @@ -41,24 +42,34 @@ class NavigationSettingsFragmentX : PreferenceFragmentCompat() {
companion object {
// Delay before restarting app to let user see the "Restarting..." toast
private const val RESTART_DELAY_FOR_TOAST_VISIBILITY_MS = 500L
private const val DIALOG_FRAGMENT_TAG = "NavigationSettingsFragmentX.DIALOG"
}

override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.navigation_preferences, rootKey)

// Set up click handler for "Switch to Classic View" button
findPreference<Preference>("switch_to_classic_view")?.setOnPreferenceClickListener {
showSwitchToClassicViewDialog()
true
}

// Set up click handler for "Switch to New View" button
findPreference<Preference>("switch_to_new_view")?.setOnPreferenceClickListener {
showSwitchToNewViewDialog()
true
}
}

override fun onDisplayPreferenceDialog(preference: Preference) {
if (preference is UpcomingTimePresetPreferenceX) {
val dialogFragment = UpcomingTimePresetPreferenceX.Dialog.newInstance(preference.key)
@Suppress("DEPRECATION")
dialogFragment.setTargetFragment(this, 0)
dialogFragment.show(parentFragmentManager, DIALOG_FRAGMENT_TAG)
} else {
super.onDisplayPreferenceDialog(preference)
}
}

private fun showSwitchToClassicViewDialog() {
val ctx = context ?: return
AlertDialog.Builder(ctx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,23 @@ object PreferenceUtils {
internal fun formatSnoozePreset(value: Long): String {
val seconds = value / 1000L

if (seconds % (3600L * 24) == 0L) {
val days = seconds / (3600L * 24)
if (seconds % Consts.WEEK_IN_SECONDS == 0L) {
val weeks = seconds / Consts.WEEK_IN_SECONDS
return "${weeks}w"
}

if (seconds % Consts.DAY_IN_SECONDS == 0L) {
val days = seconds / Consts.DAY_IN_SECONDS
return "${days}d"
}

if (seconds % 3600L == 0L) {
val hours = seconds / 3600L
if (seconds % Consts.HOUR_IN_SECONDS == 0L) {
val hours = seconds / Consts.HOUR_IN_SECONDS
return "${hours}h"
}

if (seconds % 60L == 0L) {
val minutes = seconds / 60L
if (seconds % Consts.MINUTE_IN_SECONDS == 0L) {
val minutes = seconds / Consts.MINUTE_IN_SECONDS
return "${minutes}m"
}

Expand All @@ -70,9 +75,10 @@ object PreferenceUtils {
val seconds =
when (unit) {
"s" -> num
"m" -> num * Consts.MINUTE_IN_SECONDS;
"h" -> num * Consts.HOUR_IN_SECONDS;
"d" -> num * Consts.DAY_IN_SECONDS;
"m" -> num * Consts.MINUTE_IN_SECONDS
"h" -> num * Consts.HOUR_IN_SECONDS
"d" -> num * Consts.DAY_IN_SECONDS
"w" -> num * Consts.WEEK_IN_SECONDS
else -> throw Exception("Unknown unit ${unit}")
}
seconds * 1000L
Expand Down Expand Up @@ -108,4 +114,30 @@ object PreferenceUtils {

fun formatPattern(pattern: LongArray): String =
pattern.map { p -> formatSnoozePreset(p) }.joinToString(", ")

/**
* Format a millisecond duration as a human-readable label (e.g., "8 hours", "3 days", "1 week").
* Used for display in bottom sheets and chips.
*/
fun formatPresetHumanReadable(millis: Long): String {
val seconds = millis / 1000L

if (seconds % Consts.WEEK_IN_SECONDS == 0L) {
val weeks = seconds / Consts.WEEK_IN_SECONDS
return if (weeks == 1L) "1 week" else "$weeks weeks"
}
if (seconds % Consts.DAY_IN_SECONDS == 0L) {
val days = seconds / Consts.DAY_IN_SECONDS
return if (days == 1L) "1 day" else "$days days"
}
if (seconds % Consts.HOUR_IN_SECONDS == 0L) {
val hours = seconds / Consts.HOUR_IN_SECONDS
return if (hours == 1L) "1 hour" else "$hours hours"
}
if (seconds % Consts.MINUTE_IN_SECONDS == 0L) {
val minutes = seconds / Consts.MINUTE_IN_SECONDS
return if (minutes == 1L) "1 minute" else "$minutes minutes"
}
return "$seconds seconds"
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Human-readable time strings are hardcoded in English

Low Severity

formatPresetHumanReadable returns hardcoded English strings like "hours", "days", "weeks" that appear in user-facing chip text and the bottom sheet options. Unlike other user-facing text in the app which uses Android string resources (getString(R.string.…)), this function isn't localizable. Since it's a PreferenceUtils utility without Context, it can't access resources — the callers in MainActivityModern and UpcomingTimeFilterBottomSheet (which do have Context) would need to handle formatting instead, or the function signature would need to accept a Context parameter.

Fix in Cursor Fix in Web

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//
// Calendar Notifications Plus
// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com)
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software Foundation,
// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
//

package com.github.quarck.calnotify.prefs

import android.app.AlertDialog
import android.content.Context
import android.os.Bundle
import android.util.AttributeSet
import android.view.View
import android.widget.EditText
import android.widget.TextView
import androidx.preference.DialogPreference
import androidx.preference.PreferenceDialogFragmentCompat
import com.github.quarck.calnotify.R
import com.github.quarck.calnotify.Settings

class UpcomingTimePresetPreferenceX @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.dialogPreferenceStyle,
defStyleRes: Int = 0
) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) {

var presetValue: String = Settings.DEFAULT_UPCOMING_TIME_PRESETS
private set

init {
dialogLayoutResource = R.layout.dialog_upcoming_time_presets
positiveButtonText = context.getString(android.R.string.ok)
negativeButtonText = context.getString(android.R.string.cancel)
}

fun persistPreset(value: String) {
presetValue = value
persistString(value)
notifyChanged()
}

override fun onSetInitialValue(defaultValue: Any?) {
presetValue = getPersistedString((defaultValue as? String) ?: Settings.DEFAULT_UPCOMING_TIME_PRESETS)
}

override fun onGetDefaultValue(a: android.content.res.TypedArray, index: Int): Any? {
return a.getString(index)
}

class Dialog : PreferenceDialogFragmentCompat() {
private var edit: EditText? = null

override fun onBindDialogView(view: View) {
super.onBindDialogView(view)

val pref = preference as UpcomingTimePresetPreferenceX

val label = view.findViewById<TextView>(R.id.text_label_upcoming_presets)
label?.text = getString(R.string.dialog_upcoming_time_presets_label, Settings.MAX_LOOKAHEAD_DAYS.toInt(), Settings.MAX_UPCOMING_TIME_PRESETS)

edit = view.findViewById(R.id.edit_text_upcoming_time_presets)
edit?.setText(pref.presetValue)
}

override fun onDialogClosed(positiveResult: Boolean) {
if (positiveResult) {
val value = edit?.text?.toString()

if (value != null) {
val presets = PreferenceUtils.parseSnoozePresets(value)
if (presets != null) {
// Filter out negative values
val validPresets = presets.filter { it > 0 }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dialog validates positivity but not max lookahead constraint

Medium Severity

The dialog's validPresets filter only checks it > 0, but Settings.upcomingTimePresets filters with it > 0 && it <= MAX_LOOKAHEAD_MILLIS. If a user enters presets that are all positive but exceed 30 days (e.g., "5w, 6w"), the dialog accepts and persists them (non-empty positive list), but the read path silently drops all of them. This leaves the UpcomingTimeFilterBottomSheet with zero fixed interval options and no way to select a fixed mode — despite the dialog telling the user the save succeeded.

Additional Locations (1)

Fix in Cursor Fix in Web

val newValue = if (validPresets.isEmpty()) {
Settings.DEFAULT_UPCOMING_TIME_PRESETS
} else {
value.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString(", ")
}

val pref = preference as UpcomingTimePresetPreferenceX
if (pref.callChangeListener(newValue)) {
pref.persistPreset(newValue)
}

if (validPresets.size > Settings.MAX_UPCOMING_TIME_PRESETS) {
showFormattedMessage(R.string.error_too_many_upcoming_presets, Settings.MAX_UPCOMING_TIME_PRESETS)
}
} else {
showMessage(R.string.error_cannot_parse_preset)
}
}
}
}

private fun showMessage(id: Int) {
val context = requireContext()
AlertDialog.Builder(context)
.setMessage(context.getString(id))
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.create()
.show()
}

private fun showFormattedMessage(id: Int, vararg args: Any) {
val context = requireContext()
AlertDialog.Builder(context)
.setMessage(context.getString(id, *args))
.setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> }
.create()
.show()
}

companion object {
fun newInstance(key: String): Dialog {
val fragment = Dialog()
val args = Bundle(1)
args.putString(ARG_KEY, key)
fragment.arguments = args
return fragment
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preset preference class duplicates existing SnoozePresetPreferenceX

Low Severity

UpcomingTimePresetPreferenceX is a near-duplicate of the existing SnoozePresetPreferenceX. Both extend DialogPreference, have an inner Dialog class extending PreferenceDialogFragmentCompat, bind an EditText, parse via PreferenceUtils.parseSnoozePresets(), reformat on save, persist via persistString, and show error alerts. The differences (label TextView, negative-value filtering, max-count threshold) are minor enough to share a base class or parameterized implementation.

Fix in Cursor Fix in Web

Loading
Loading