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()
+ )
+ }
+}