diff --git a/app/detekt-baseline.xml b/app/detekt-baseline.xml index 12dc7d2b5..4290ebed8 100644 --- a/app/detekt-baseline.xml +++ b/app/detekt-baseline.xml @@ -127,6 +127,7 @@ MagicNumber:EventListWidgetAdapter.kt$EventListWidgetAdapter$999 MagicNumber:EventsDatabase.kt$EventsDatabase.Companion.<no name provided>$10 MagicNumber:EventsDatabase.kt$EventsDatabase.Companion.<no name provided>$11 + MagicNumber:EventsDatabase.kt$EventsDatabase.Companion.<no name provided>$12 MagicNumber:EventsDatabase.kt$EventsDatabase.Companion.<no name provided>$3 MagicNumber:EventsDatabase.kt$EventsDatabase.Companion.<no name provided>$4 MagicNumber:EventsDatabase.kt$EventsDatabase.Companion.<no name provided>$5 @@ -449,6 +450,7 @@ VariableNaming:CalendarPickerActivity.kt$CalendarPickerActivity$private val TYPE_TASK = 1 VariableNaming:DayFragmentsHolder.kt$DayFragmentsHolder$private val PREFILLED_DAYS = 251 VariableNaming:EventActivity.kt$EventActivity$private val LAT_LON_PATTERN = "^[-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?)([,;])\\s*[-+]?(180(\\.0+)?|((1[0-7]\\d)|([1-9]?\\d))(\\.\\d+)?)\$" + VariableNaming:EventActivity.kt$EventActivity$private val SELECT_END_TIME_ZONE_INTENT = 2 VariableNaming:EventActivity.kt$EventActivity$private val SELECT_TIME_ZONE_INTENT = 1 VariableNaming:EventListWidgetAdapter.kt$EventListWidgetAdapter$private val ITEM_EVENT = 0 VariableNaming:EventListWidgetAdapter.kt$EventListWidgetAdapter$private val ITEM_SECTION_DAY = 1 diff --git a/app/schemas/org.fossify.calendar.databases.EventsDatabase/12.json b/app/schemas/org.fossify.calendar.databases.EventsDatabase/12.json new file mode 100644 index 000000000..f3dab2ef9 --- /dev/null +++ b/app/schemas/org.fossify.calendar.databases.EventsDatabase/12.json @@ -0,0 +1,385 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "21ebd86b4f5800075d71125ff5b9fb86", + "entities": [ + { + "tableName": "events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `start_ts` INTEGER NOT NULL, `end_ts` INTEGER NOT NULL, `title` TEXT NOT NULL, `location` TEXT NOT NULL, `description` TEXT NOT NULL, `reminder_1_minutes` INTEGER NOT NULL, `reminder_2_minutes` INTEGER NOT NULL, `reminder_3_minutes` INTEGER NOT NULL, `reminder_1_type` INTEGER NOT NULL, `reminder_2_type` INTEGER NOT NULL, `reminder_3_type` INTEGER NOT NULL, `repeat_interval` INTEGER NOT NULL, `repeat_rule` INTEGER NOT NULL, `repeat_limit` INTEGER NOT NULL, `repetition_exceptions` TEXT NOT NULL, `attendees` TEXT NOT NULL, `import_id` TEXT NOT NULL, `time_zone` TEXT NOT NULL, `end_time_zone` TEXT NOT NULL, `flags` INTEGER NOT NULL, `event_type` INTEGER NOT NULL, `parent_id` INTEGER NOT NULL, `last_updated` INTEGER NOT NULL, `source` TEXT NOT NULL, `availability` INTEGER NOT NULL, `access_level` INTEGER NOT NULL, `color` INTEGER NOT NULL, `type` INTEGER NOT NULL, `status` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "startTS", + "columnName": "start_ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTS", + "columnName": "end_ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reminder1Minutes", + "columnName": "reminder_1_minutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminder2Minutes", + "columnName": "reminder_2_minutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminder3Minutes", + "columnName": "reminder_3_minutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminder1Type", + "columnName": "reminder_1_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminder2Type", + "columnName": "reminder_2_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminder3Type", + "columnName": "reminder_3_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatInterval", + "columnName": "repeat_interval", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatRule", + "columnName": "repeat_rule", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatLimit", + "columnName": "repeat_limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repetitionExceptions", + "columnName": "repetition_exceptions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attendees", + "columnName": "attendees", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "importId", + "columnName": "import_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeZone", + "columnName": "time_zone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endTimeZone", + "columnName": "end_time_zone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calendarId", + "columnName": "event_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "availability", + "columnName": "availability", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessLevel", + "columnName": "access_level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_events_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_events_id` ON `${TABLE_NAME}` (`id`)" + } + ] + }, + { + "tableName": "event_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `color` INTEGER NOT NULL, `caldav_calendar_id` INTEGER NOT NULL, `caldav_display_name` TEXT NOT NULL, `caldav_email` TEXT NOT NULL, `type` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "caldavCalendarId", + "columnName": "caldav_calendar_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "caldavDisplayName", + "columnName": "caldav_display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caldavEmail", + "columnName": "caldav_email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_types_id", + "unique": true, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_event_types_id` ON `${TABLE_NAME}` (`id`)" + } + ] + }, + { + "tableName": "widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `widget_id` INTEGER NOT NULL, `period` INTEGER NOT NULL, `header` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "widgetId", + "columnName": "widget_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "period", + "columnName": "period", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "header", + "columnName": "header", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_widgets_widget_id", + "unique": true, + "columnNames": [ + "widget_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_widgets_widget_id` ON `${TABLE_NAME}` (`widget_id`)" + } + ] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `task_id` INTEGER NOT NULL, `start_ts` INTEGER NOT NULL, `flags` INTEGER NOT NULL, FOREIGN KEY(`task_id`) REFERENCES `events`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "task_id", + "columnName": "task_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startTS", + "columnName": "start_ts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tasks_id_task_id", + "unique": true, + "columnNames": [ + "id", + "task_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tasks_id_task_id` ON `${TABLE_NAME}` (`id`, `task_id`)" + } + ], + "foreignKeys": [ + { + "table": "events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "task_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '21ebd86b4f5800075d71125ff5b9fb86')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/fossify/calendar/activities/EventActivity.kt b/app/src/main/kotlin/org/fossify/calendar/activities/EventActivity.kt index 9bdecd7b6..dc794941e 100644 --- a/app/src/main/kotlin/org/fossify/calendar/activities/EventActivity.kt +++ b/app/src/main/kotlin/org/fossify/calendar/activities/EventActivity.kt @@ -42,6 +42,7 @@ import org.fossify.calendar.dialogs.SelectEventColorDialog import org.fossify.calendar.extensions.calDAVHelper import org.fossify.calendar.extensions.calendarsDB import org.fossify.calendar.extensions.cancelNotification +import org.fossify.calendar.extensions.computeStartEndSeconds import org.fossify.calendar.extensions.config import org.fossify.calendar.extensions.eventsDB import org.fossify.calendar.extensions.eventsHelper @@ -173,6 +174,7 @@ class EventActivity : SimpleActivity() { private val LAT_LON_PATTERN = "^[-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?)([,;])\\s*[-+]?(180(\\.0+)?|((1[0-7]\\d)|([1-9]?\\d))(\\.\\d+)?)\$" private val SELECT_TIME_ZONE_INTENT = 1 + private val SELECT_END_TIME_ZONE_INTENT = 2 private var mIsAllDayEvent = false private var mReminder1Minutes = REMINDER_OFF @@ -200,6 +202,7 @@ class EventActivity : SimpleActivity() { private var mStatus = Events.STATUS_CONFIRMED private var mStoredCalendars = ArrayList() private var mOriginalTimeZone = DateTimeZone.getDefault().id + private var mOriginalEndTimeZone = "" private var mOriginalStartTS = 0L private var mOriginalEndTS = 0L private var mIsNewEvent = true @@ -350,9 +353,15 @@ class EventActivity : SimpleActivity() { savedInstanceState.apply { mEvent = getSerializable(EVENT) as Event + mEvent.timeZone = getString(TIME_ZONE) ?: TimeZone.getDefault().id mEventStartDateTime = Formatter.getDateTimeFromTS(getLong(START_TS)) mEventEndDateTime = Formatter.getDateTimeFromTS(getLong(END_TS)) - mEvent.timeZone = getString(TIME_ZONE) ?: TimeZone.getDefault().id + if (config.allowChangingTimeZones && !mEvent.getIsAllDay()) { + mEventStartDateTime = + mEventStartDateTime.withZone(DateTimeZone.forID(mEvent.getTimeZoneString())) + mEventEndDateTime = + mEventEndDateTime.withZone(DateTimeZone.forID(mEvent.getEndTimeZoneString())) + } mReminder1Minutes = getInt(REMINDER_1_MINUTES) mReminder2Minutes = getInt(REMINDER_2_MINUTES) @@ -395,12 +404,16 @@ class EventActivity : SimpleActivity() { } override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) { - if ( - requestCode == SELECT_TIME_ZONE_INTENT - && resultCode == RESULT_OK && resultData?.hasExtra(TIME_ZONE) == true - ) { + val isTimeZoneResult = + requestCode == SELECT_TIME_ZONE_INTENT || requestCode == SELECT_END_TIME_ZONE_INTENT + if (isTimeZoneResult && resultCode == RESULT_OK && resultData?.hasExtra(TIME_ZONE) == true) { val timeZone = resultData.getSerializableExtra(TIME_ZONE) as MyTimeZone - mEvent.timeZone = timeZone.zoneName + if (requestCode == SELECT_END_TIME_ZONE_INTENT) { + mEvent.endTimeZone = timeZone.zoneName + } else { + // an empty end zone keeps following the start zone (DigiCal-style seeding) + mEvent.timeZone = timeZone.zoneName + } updateTimeZoneText() } super.onActivityResult(requestCode, resultCode, resultData) @@ -484,7 +497,8 @@ class EventActivity : SimpleActivity() { eventStartTime.setOnClickListener { setupStartTime() } eventEndDate.setOnClickListener { setupEndDate() } eventEndTime.setOnClickListener { setupEndTime() } - eventTimeZone.setOnClickListener { setupTimeZone() } + eventStartTimeZone.setOnClickListener { setupTimeZone(isEnd = false) } + eventEndTimeZone.setOnClickListener { setupTimeZone(isEnd = true) } eventAllDay.setOnCheckedChangeListener { _, isChecked -> toggleAllDay(isChecked) } eventRepetition.setOnClickListener { showRepeatIntervalDialog() } @@ -611,22 +625,11 @@ class EventActivity : SimpleActivity() { val newEndTS = mEventEndDateTime.withTimeAtStartOfDay().withHourOfDay(12).seconds() return Pair(newStartTS, newEndTS) } else { - val offset = if ( - !config.allowChangingTimeZones - || mEvent.getTimeZoneString().equals(mOriginalTimeZone, true) - ) { - 0 - } else { - val original = mOriginalTimeZone.ifEmpty { DateTimeZone.getDefault().id } - val millis = System.currentTimeMillis() - val newOffset = DateTimeZone.forID(mEvent.getTimeZoneString()).getOffset(millis) - val oldOffset = DateTimeZone.forID(original).getOffset(millis) - (newOffset - oldOffset) / 1000L - } - - val newStartTS = mEventStartDateTime.seconds() - offset - val newEndTS = mEventEndDateTime.seconds() - offset - return Pair(newStartTS, newEndTS) + // interpret each wall-clock in its own zone; empty end zone follows the start zone + return computeStartEndSeconds( + mEventStartDateTime, mEvent.getTimeZoneString(), + mEventEndDateTime, mEvent.getEndTimeZoneString() + ) } } @@ -662,7 +665,6 @@ class EventActivity : SimpleActivity() { return binding.eventTitle.text.toString() != mEvent.title || binding.eventLocation.text.toString() != mEvent.location || binding.eventDescription.text.toString() != mEvent.description || - binding.eventTimeZone.text != mEvent.getTimeZoneString() || reminders != mEvent.getReminders() || mRepeatInterval != mEvent.repeatInterval || mRepeatRule != mEvent.repeatRule || @@ -675,6 +677,9 @@ class EventActivity : SimpleActivity() { mWasCalendarChanged || mIsAllDayEvent != mEvent.getIsAllDay() || mEventColor != mEvent.color || + (!mIsNewEvent && + (mEvent.timeZone != mOriginalTimeZone || + mEvent.endTimeZone != mOriginalEndTimeZone)) || hasTimeChanged } @@ -714,12 +719,13 @@ class EventActivity : SimpleActivity() { window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) binding.eventToolbar.title = getString(R.string.edit_event) mOriginalTimeZone = mEvent.timeZone + mOriginalEndTimeZone = mEvent.endTimeZone if (config.allowChangingTimeZones) { try { mEventStartDateTime = Formatter.getDateTimeFromTS(realStart) - .withZone(DateTimeZone.forID(mOriginalTimeZone)) + .withZone(DateTimeZone.forID(mEvent.getTimeZoneString())) mEventEndDateTime = Formatter.getDateTimeFromTS(realStart + duration) - .withZone(DateTimeZone.forID(mOriginalTimeZone)) + .withZone(DateTimeZone.forID(mEvent.getEndTimeZoneString())) } catch (e: Exception) { showErrorToast(e) mEventStartDateTime = Formatter.getDateTimeFromTS(realStart) @@ -1509,6 +1515,11 @@ class EventActivity : SimpleActivity() { } mIsAllDayEvent = isAllDay + if (isAllDay) { + // an all-day event carries a single zone; drop any distinct end zone now so it + // does not silently reappear if the user toggles all-day back off + mEvent.endTimeZone = "" + } binding.eventStartTime.beGoneIf(isAllDay) binding.eventEndTime.beGoneIf(isAllDay) updateTimeZoneText() @@ -1518,9 +1529,9 @@ class EventActivity : SimpleActivity() { private fun showOrHideTimeZone() { val allowChangingTimeZones = config.allowChangingTimeZones && !mIsAllDayEvent - binding.eventTimeZoneDivider.beVisibleIf(allowChangingTimeZones) - binding.eventTimeZoneImage.beVisibleIf(allowChangingTimeZones) - binding.eventTimeZone.beVisibleIf(allowChangingTimeZones) + binding.eventStartTimeZone.beVisibleIf(allowChangingTimeZones) + binding.eventEndTimeZone.beVisibleIf(allowChangingTimeZones) + updateLocalTimeText() } private fun shareEvent() { @@ -1686,6 +1697,7 @@ class EventActivity : SimpleActivity() { importId = newImportId timeZone = if (mIsAllDayEvent || timeZone.isEmpty()) DateTimeZone.getDefault().id else timeZone + endTimeZone = if (mIsAllDayEvent) "" else endTimeZone flags = mEvent.flags.addBitIf(binding.eventAllDay.isChecked, FLAG_ALL_DAY) repeatLimit = if (repeatInterval == 0) 0 else mRepeatLimit repeatRule = mRepeatRule @@ -1831,7 +1843,56 @@ class EventActivity : SimpleActivity() { } private fun updateTimeZoneText() { - binding.eventTimeZone.text = mEvent.getTimeZoneString() + val startZone = mEvent.getTimeZoneString() + val endZone = mEvent.getEndTimeZoneString() + binding.eventStartTimeZone.apply { + text = formatTimeZoneLabel(startZone, mEventStartDateTime.millis) + contentDescription = "${getString(R.string.start_time_zone)}: $text" + } + binding.eventEndTimeZone.apply { + text = formatTimeZoneLabel(endZone, mEventEndDateTime.millis) + contentDescription = "${getString(R.string.end_time_zone)}: $text" + } + updateLocalTimeText(startZone, endZone) + } + + // Label a zone with its GMT offset at the given instant, so the start and end labels + // each reflect their own moment (which can differ across a DST transition). + private fun formatTimeZoneLabel(zoneId: String, atMillis: Long): String { + val offset = DateTimeZone.forID(zoneId).getOffset(atMillis) / 1000 / 60 + val sign = if (offset < 0) "-" else "+" + val hours = Math.abs(offset) / 60 + val minutes = Math.abs(offset) % 60 + val gmt = if (minutes == 0) "GMT$sign$hours" else "GMT$sign$hours:%02d".format(minutes) + return "$zoneId ($gmt)" + } + + private fun updateLocalTimeText( + startZone: String = mEvent.getTimeZoneString(), + endZone: String = mEvent.getEndTimeZoneString(), + ) { + val deviceZone = DateTimeZone.getDefault() + val (startTS, endTS) = computeStartEndSeconds( + mEventStartDateTime, startZone, mEventEndDateTime, endZone + ) + // Show the readout only when the event's zones actually resolve to a different UTC + // offset than the device zone, so equivalent aliases (e.g. Etc/UTC vs UTC) don't trigger it. + val differs = + DateTimeZone.forID(startZone).getOffset(startTS * 1000L) != + deviceZone.getOffset(startTS * 1000L) || + DateTimeZone.forID(endZone).getOffset(endTS * 1000L) != + deviceZone.getOffset(endTS * 1000L) + binding.eventLocalTime.beVisibleIf( + differs && config.allowChangingTimeZones && !mIsAllDayEvent + ) + if (!differs) { + return + } + + val localStart = Formatter.getTimeFromTS(this, startTS) + val localEnd = Formatter.getTimeFromTS(this, endTS) + binding.eventLocalTime.text = + getString(R.string.in_local_time, deviceZone.id, localStart, localEnd) } private fun checkStartEndValidity() { @@ -2020,11 +2081,17 @@ class EventActivity : SimpleActivity() { } } - private fun setupTimeZone() { + private fun setupTimeZone(isEnd: Boolean) { hideKeyboard() Intent(this, SelectTimeZoneActivity::class.java).apply { - putExtra(CURRENT_TIME_ZONE, mEvent.getTimeZoneString()) - startActivityForResult(this, SELECT_TIME_ZONE_INTENT) + putExtra( + CURRENT_TIME_ZONE, + if (isEnd) mEvent.getEndTimeZoneString() else mEvent.getTimeZoneString() + ) + startActivityForResult( + this, + if (isEnd) SELECT_END_TIME_ZONE_INTENT else SELECT_TIME_ZONE_INTENT + ) } } @@ -2403,7 +2470,6 @@ class EventActivity : SimpleActivity() { val textColor = getProperTextColor() arrayOf( eventTimeImage, - eventTimeZoneImage, eventRepetitionImage, eventReminderImage, eventCalendarImage, @@ -2418,6 +2484,11 @@ class EventActivity : SimpleActivity() { ).forEach { it.applyColorFilter(textColor) } + + // the local-time readout's globe is a compound drawable, not an ImageView, so it is + // tinted separately to match the adaptive text color (it would otherwise render in its + // baked-in color and be invisible on light themes) + eventLocalTime.compoundDrawablesRelative.forEach { it?.applyColorFilter(textColor) } } private fun updateActionBarTitle() { diff --git a/app/src/main/kotlin/org/fossify/calendar/databases/EventsDatabase.kt b/app/src/main/kotlin/org/fossify/calendar/databases/EventsDatabase.kt index 04011454a..aabbb40b3 100644 --- a/app/src/main/kotlin/org/fossify/calendar/databases/EventsDatabase.kt +++ b/app/src/main/kotlin/org/fossify/calendar/databases/EventsDatabase.kt @@ -24,7 +24,7 @@ import java.util.concurrent.Executors @Database( entities = [Event::class, CalendarEntity::class, Widget::class, Task::class], - version = 11 + version = 12 ) @TypeConverters(Converters::class) abstract class EventsDatabase : RoomDatabase() { @@ -65,6 +65,7 @@ abstract class EventsDatabase : RoomDatabase() { .addMigrations(MIGRATION_8_9) .addMigrations(MIGRATION_9_10) .addMigrations(MIGRATION_10_11) + .addMigrations(MIGRATION_11_12) .build() db!!.openHelper.setWriteAheadLoggingEnabled(true) } @@ -182,5 +183,11 @@ abstract class EventsDatabase : RoomDatabase() { } } } + + private val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE events ADD COLUMN end_time_zone TEXT NOT NULL DEFAULT ''") + } + } } } diff --git a/app/src/main/kotlin/org/fossify/calendar/extensions/DateTime.kt b/app/src/main/kotlin/org/fossify/calendar/extensions/DateTime.kt index 0cd9be321..6968d0d31 100644 --- a/app/src/main/kotlin/org/fossify/calendar/extensions/DateTime.kt +++ b/app/src/main/kotlin/org/fossify/calendar/extensions/DateTime.kt @@ -1,5 +1,30 @@ package org.fossify.calendar.extensions import org.joda.time.DateTime +import org.joda.time.DateTimeZone fun DateTime.seconds() = millis / 1000L + +// Interpret the start wall-clock in startZone and the end wall-clock in endZone, +// returning the two absolute instants (epoch seconds). The DateTimes' own zones are +// ignored; only their wall-clock fields are used. +fun computeStartEndSeconds( + start: DateTime, + startZone: String, + end: DateTime, + endZone: String, +): Pair { + val startTS = retainFieldsInZoneSeconds(start, DateTimeZone.forID(startZone)) + val endTS = retainFieldsInZoneSeconds(end, DateTimeZone.forID(endZone)) + return startTS to endTS +} + +// Reinterpret a DateTime's wall-clock fields in [zone] and return the epoch seconds. +// Unlike DateTime.withZoneRetainFields(), this tolerates a wall-clock time that falls in a +// DST spring-forward gap (which would otherwise throw IllegalInstantException and crash the +// save) by resolving it leniently to the instant just after the transition. +private fun retainFieldsInZoneSeconds(dateTime: DateTime, zone: DateTimeZone): Long { + // The wall-clock fields read as if they were UTC; UTC has no gaps so this never throws. + val localMillis = dateTime.withZoneRetainFields(DateTimeZone.UTC).millis + return zone.convertLocalToUTC(localMillis, false) / 1000L +} diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/CalDAVHelper.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/CalDAVHelper.kt index ebe561688..6d82335cf 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/CalDAVHelper.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/CalDAVHelper.kt @@ -206,6 +206,7 @@ class CalDAVHelper(val context: Context) { Events.ORIGINAL_INSTANCE_TIME, Events.EVENT_LOCATION, Events.EVENT_TIMEZONE, + Events.EVENT_END_TIMEZONE, Events.CALENDAR_TIME_ZONE, Events.DELETED, Events.AVAILABILITY, @@ -258,6 +259,7 @@ class CalDAVHelper(val context: Context) { val importId = getCalDAVEventImportId(calendarId, id) val eventTimeZone = cursor.getStringValue(Events.EVENT_TIMEZONE) ?: cursor.getStringValue(Events.CALENDAR_TIME_ZONE) ?: DateTimeZone.getDefault().id + val eventEndTimeZone = cursor.getStringValue(Events.EVENT_END_TIMEZONE) ?: "" val source = "$CALDAV-$calendarId" val repeatRule = Parser().parseRepeatInterval(rrule, startTS) @@ -281,6 +283,7 @@ class CalDAVHelper(val context: Context) { attendees = attendees, importId = importId, timeZone = eventTimeZone, + endTimeZone = eventEndTimeZone, flags = allDay, calendarId = localCalendarId, source = source, @@ -564,6 +567,7 @@ class CalDAVHelper(val context: Context) { put(Events.DTSTART, event.startTS * 1000L) put(Events.EVENT_TIMEZONE, event.getTimeZoneString()) + put(Events.EVENT_END_TIMEZONE, event.getEndTimeZoneString()) if (event.repeatInterval > 0) { put(Events.DURATION, getDurationCode(event)) putNull(Events.DTEND) @@ -639,6 +643,7 @@ class CalDAVHelper(val context: Context) { put(Events.DTSTART, startMillis) put(Events.DTEND, startMillis + durationMillis) put(Events.EVENT_TIMEZONE, parentEvent.getTimeZoneString()) + put(Events.EVENT_END_TIMEZONE, parentEvent.getEndTimeZoneString()) put(Events.ORIGINAL_ID, parentEvent.getCalDAVEventId()) put(Events.ORIGINAL_INSTANCE_TIME, startMillis) put(Events.STATUS, Events.STATUS_CANCELED) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsImporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsImporter.kt index 714d014e6..bf896a476 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsImporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsImporter.kt @@ -34,6 +34,8 @@ class IcsImporter(val activity: SimpleActivity) { private var curLocation = "" private var curDescription = "" private var curImportId = "" + private var curTimeZone = "" + private var curEndTimeZone = "" private var curRecurrenceDayCode = "" private var curRrule = "" private var curFlags = 0 @@ -117,6 +119,7 @@ class IcsImporter(val activity: SimpleActivity) { } else if (line.startsWith(DTSTART)) { if (isParsingEvent || isParsingTask) { curStart = getTimestamp(line.substring(DTSTART.length)) + parseTimeZoneId(line.substring(DTSTART.length))?.let { curTimeZone = it } if (curRrule != "") { parseRepeatRule() @@ -129,6 +132,7 @@ class IcsImporter(val activity: SimpleActivity) { } } else if (line.startsWith(DTEND)) { curEnd = getTimestamp(line.substring(DTEND.length)) + parseTimeZoneId(line.substring(DTEND.length))?.let { curEndTimeZone = it } } else if (line.startsWith(DURATION)) { val durationString = line.substring(DURATION.length) curDuration = Parser().parseDurationSeconds(durationString) @@ -334,7 +338,8 @@ class IcsImporter(val activity: SimpleActivity) { curRepeatExceptions, emptyList(), curImportId, - DateTimeZone.getDefault().id, + curTimeZone.ifEmpty { DateTimeZone.getDefault().id }, + curEndTimeZone, curFlags, curCalendarId, 0, @@ -535,6 +540,8 @@ class IcsImporter(val activity: SimpleActivity) { curLocation = "" curDescription = "" curImportId = "" + curTimeZone = "" + curEndTimeZone = "" curRecurrenceDayCode = "" curRrule = "" curFlags = 0 @@ -557,3 +564,15 @@ class IcsImporter(val activity: SimpleActivity) { curColor = 0 } } + +// extracts a known timezone id from an ICS property's parameters, e.g. +// ";TZID=America/New_York:20260830T070000" -> "America/New_York"; null if absent or unknown. +// RFC 5545 also permits the value to be quoted (;TZID="America/New_York":...), so surrounding +// double quotes are stripped before validation. +fun parseTimeZoneId(fullString: String): String? { + if (!fullString.startsWith(';') || !fullString.contains(':')) { + return null + } + val timeZoneId = fullString.substringAfter("%s=".format(TZID)).substringBefore(':').trim('"') + return if (DateTimeZone.getAvailableIDs().contains(timeZoneId)) timeZoneId else null +} diff --git a/app/src/main/kotlin/org/fossify/calendar/models/Event.kt b/app/src/main/kotlin/org/fossify/calendar/models/Event.kt index 74312ff04..2000c4766 100644 --- a/app/src/main/kotlin/org/fossify/calendar/models/Event.kt +++ b/app/src/main/kotlin/org/fossify/calendar/models/Event.kt @@ -57,6 +57,7 @@ data class Event( @ColumnInfo(name = "attendees") var attendees: List = emptyList(), @ColumnInfo(name = "import_id") var importId: String = "", @ColumnInfo(name = "time_zone") var timeZone: String = "", + @ColumnInfo(name = "end_time_zone") var endTimeZone: String = "", @ColumnInfo(name = "flags") var flags: Int = 0, @ColumnInfo(name = "event_type") var calendarId: Long = LOCAL_CALENDAR_ID, @ColumnInfo(name = "parent_id") var parentId: Long = 0, @@ -254,14 +255,25 @@ data class Event( } fun getTimeZoneString(): String { - return if (timeZone.isNotEmpty() && getAllTimeZones().map { it.zoneName } - .contains(timeZone)) { + return if (timeZone.isNotEmpty() && DateTimeZone.getAvailableIDs().contains(timeZone)) { timeZone } else { DateTimeZone.getDefault().id } } + // the end zone falls back to the start zone when unset or unknown, so an empty + // end_time_zone means "same as start" and existing events are unaffected. Validity is + // checked against the same set of ids that ICS/CalDAV import accepts (getAvailableIDs), + // so a legitimately-imported zone alias is not silently dropped. + fun getEndTimeZoneString(): String { + return if (endTimeZone.isNotEmpty() && DateTimeZone.getAvailableIDs().contains(endTimeZone)) { + endTimeZone + } else { + getTimeZoneString() + } + } + fun isAttendeeInviteDeclined() = attendees.any { it.isMe && it.status == Attendees.ATTENDEE_STATUS_DECLINED } diff --git a/app/src/main/res/layout/activity_event.xml b/app/src/main/res/layout/activity_event.xml index 77b6376b4..c54c67031 100644 --- a/app/src/main/res/layout/activity_event.xml +++ b/app/src/main/res/layout/activity_event.xml @@ -165,11 +165,27 @@ android:textSize="@dimen/day_text_size" tools:text="00:00" /> + + - - - - + android:paddingStart="@dimen/activity_margin" + android:paddingEnd="@dimen/activity_margin" + android:paddingBottom="@dimen/small_margin" + android:textSize="@dimen/smaller_text_size" + tools:text="America/Los_Angeles (GMT-7)" /> + + Highlight weekends on some views Color of highlighted weekends Allow changing event time zones + Start time zone + End time zone + In %1$s: %2$s – %3$s Manage quick filter calendars Allow creating tasks diff --git a/app/src/test/kotlin/org/fossify/calendar/extensions/ComputeStartEndSecondsTest.kt b/app/src/test/kotlin/org/fossify/calendar/extensions/ComputeStartEndSecondsTest.kt new file mode 100644 index 000000000..aea9b5bb7 --- /dev/null +++ b/app/src/test/kotlin/org/fossify/calendar/extensions/ComputeStartEndSecondsTest.kt @@ -0,0 +1,58 @@ +package org.fossify.calendar.extensions + +import org.joda.time.DateTime +import org.joda.time.DateTimeZone +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ComputeStartEndSecondsTest { + // Tokyo (JST, +9) departure 07:00, Colombo (+0530) arrival 08:30, same date. + @Test + fun differentZonesProduceIndependentInstants() { + val startWall = DateTime(2026, 8, 30, 7, 0, DateTimeZone.UTC) + val endWall = DateTime(2026, 8, 30, 8, 30, DateTimeZone.UTC) + val (startTS, endTS) = computeStartEndSeconds( + startWall, "Asia/Tokyo", endWall, "Asia/Colombo" + ) + // 07:00 JST == 22:00 UTC on 2026-08-29 + assertEquals(DateTime(2026, 8, 29, 22, 0, DateTimeZone.UTC).millis / 1000L, startTS) + // 08:30 +0530 == 03:00 UTC on 2026-08-30 + assertEquals(DateTime(2026, 8, 30, 3, 0, DateTimeZone.UTC).millis / 1000L, endTS) + // Flight duration is 5 hours + assertEquals(5 * 60 * 60L, endTS - startTS) + } + + @Test + fun sameZoneMatchesPlainConversion() { + val startWall = DateTime(2026, 1, 1, 9, 0, DateTimeZone.UTC) + val endWall = DateTime(2026, 1, 1, 10, 0, DateTimeZone.UTC) + val (startTS, endTS) = computeStartEndSeconds( + startWall, "Europe/London", endWall, "Europe/London" + ) + assertEquals( + startWall.withZoneRetainFields(DateTimeZone.forID("Europe/London")).millis / 1000L, + startTS + ) + assertEquals( + endWall.withZoneRetainFields(DateTimeZone.forID("Europe/London")).millis / 1000L, + endTS + ) + } + + // A wall-clock time inside a DST spring-forward gap must not crash the conversion. + @Test + fun springForwardGapTimeIsResolvedLeniently() { + // America/New_York springs forward 2026-03-08 02:00 -> 03:00, so 02:30 does not exist. + val gapWall = DateTime(2026, 3, 8, 2, 30, DateTimeZone.UTC) + val endWall = DateTime(2026, 3, 8, 4, 30, DateTimeZone.UTC) + val (startTS, _) = computeStartEndSeconds( + gapWall, "America/New_York", endWall, "America/New_York" + ) + // It resolves to a real instant; viewed back in New York the hour is never the + // nonexistent 02:00 (it is shifted to the post-transition side). + val resolvedHour = + DateTime(startTS * 1000L, DateTimeZone.forID("America/New_York")).hourOfDay + assertNotEquals(2, resolvedHour) + } +} diff --git a/app/src/test/kotlin/org/fossify/calendar/helpers/ParseTimeZoneIdTest.kt b/app/src/test/kotlin/org/fossify/calendar/helpers/ParseTimeZoneIdTest.kt new file mode 100644 index 000000000..f0daceaa2 --- /dev/null +++ b/app/src/test/kotlin/org/fossify/calendar/helpers/ParseTimeZoneIdTest.kt @@ -0,0 +1,41 @@ +package org.fossify.calendar.helpers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ParseTimeZoneIdTest { + @Test + fun extractsTzidFromPropertyParameters() { + assertEquals( + "America/New_York", + parseTimeZoneId(";TZID=America/New_York:20260830T070000") + ) + } + + @Test + fun returnsNullWhenNoTzid() { + assertNull(parseTimeZoneId(":20260830T070000Z")) + } + + @Test + fun returnsNullForUnknownZone() { + assertNull(parseTimeZoneId(";TZID=Not/AZone:20260830T070000")) + } + + @Test + fun extractsQuotedTzid() { + assertEquals( + "America/New_York", + parseTimeZoneId(";TZID=\"America/New_York\":20260830T070000") + ) + } + + @Test + fun extractsTzidAfterAnotherParameter() { + assertEquals( + "America/New_York", + parseTimeZoneId(";VALUE=DATE-TIME;TZID=America/New_York:20260830T070000") + ) + } +} diff --git a/app/src/test/kotlin/org/fossify/calendar/models/EventEndTimeZoneTest.kt b/app/src/test/kotlin/org/fossify/calendar/models/EventEndTimeZoneTest.kt new file mode 100644 index 000000000..648b9fdbc --- /dev/null +++ b/app/src/test/kotlin/org/fossify/calendar/models/EventEndTimeZoneTest.kt @@ -0,0 +1,37 @@ +package org.fossify.calendar.models + +import org.junit.Assert.assertEquals +import org.junit.Test + +class EventEndTimeZoneTest { + private fun event(tz: String, endTz: String) = + Event(id = null, startTS = 0, endTS = 0, timeZone = tz, endTimeZone = endTz) + + @Test + fun emptyEndZoneFallsBackToStartZone() { + assertEquals("Europe/London", event("Europe/London", "").getEndTimeZoneString()) + } + + @Test + fun invalidEndZoneFallsBackToStartZone() { + assertEquals("Europe/London", event("Europe/London", "Not/AZone").getEndTimeZoneString()) + } + + @Test + fun validEndZoneIsReturned() { + assertEquals( + "America/Los_Angeles", + event("Europe/London", "America/Los_Angeles").getEndTimeZoneString() + ) + } + + // Aliases such as US/Eastern are accepted by ICS/CalDAV import (DateTimeZone.getAvailableIDs), + // so the read path must keep them rather than silently dropping back to the start zone. + @Test + fun aliasEndZoneIsAccepted() { + assertEquals( + "US/Eastern", + event("America/Chicago", "US/Eastern").getEndTimeZoneString() + ) + } +}