diff --git a/app/src/androidTest/java/com/todoroo/astrid/gcal/GCalHelperTest.kt b/app/src/androidTest/java/com/todoroo/astrid/gcal/GCalHelperTest.kt new file mode 100644 index 000000000..1f6d7810d --- /dev/null +++ b/app/src/androidTest/java/com/todoroo/astrid/gcal/GCalHelperTest.kt @@ -0,0 +1,142 @@ +package com.todoroo.astrid.gcal + +import android.Manifest +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.provider.CalendarContract +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Events +import androidx.core.net.toUri +import androidx.test.core.app.ApplicationProvider +import androidx.test.rule.GrantPermissionRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.tasks.TestUtilities.withTZ +import org.tasks.data.entity.Task +import org.tasks.injection.InjectingTestCase +import org.tasks.time.DateTime +import timber.log.Timber +import javax.inject.Inject + +@HiltAndroidTest +class GCalHelperTest : InjectingTestCase() { + + @get:Rule + val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.READ_CALENDAR, + Manifest.permission.WRITE_CALENDAR + ) + + @Inject lateinit var gcalHelper: GCalHelper + + private var testCalendarId: Long = -1 + + @Before + override fun setUp() { + super.setUp() + testCalendarId = createTestCalendar() + } + + @After + fun tearDown() { + if (testCalendarId > 0) { + try { + val context = ApplicationProvider.getApplicationContext() + context.contentResolver.delete( + ContentUris.withAppendedId(Calendars.CONTENT_URI, testCalendarId), + null, + null + ) + } catch (e: Exception) { + Timber.e(e) + } + } + } + + @Test fun allDayEventInNewYork() = assertAllDayEvent("America/New_York") // UTC-5 + @Test fun allDayEventInBerlin() = assertAllDayEvent("Europe/Berlin") // UTC+1 + @Test fun allDayEventInAuckland() = assertAllDayEvent("Pacific/Auckland") // UTC+13 + @Test fun allDayEventInTokyo() = assertAllDayEvent("Asia/Tokyo") // UTC+9 + @Test fun allDayEventInHonolulu() = assertAllDayEvent("Pacific/Honolulu") // UTC-10 + @Test fun allDayEventInChatham() = assertAllDayEvent("Pacific/Chatham") // UTC+13:45 + + private fun assertAllDayEvent(timezone: String) = withTZ(timezone) { + val task = Task(dueDate = DateTime(2024, 12, 20).millis) + + val eventUri = gcalHelper.createTaskEvent(task, testCalendarId.toString()) + ?: throw RuntimeException("Event not created") + + val event = queryEvent(eventUri.toString()) ?: throw RuntimeException("Event not found") + + assertEquals( + "DTSTART should be Dec 20 00:00 UTC", + DateTime(2024, 12, 20, timeZone = DateTime.UTC).millis, + event.dtStart + ) + assertEquals( + "DTEND should be Dec 21 00:00 UTC", + DateTime(2024, 12, 21, timeZone = DateTime.UTC).millis, + event.dtEnd + ) + } + + private fun createTestCalendar(): Long { + val context = ApplicationProvider.getApplicationContext() + val values = ContentValues().apply { + put(Calendars.ACCOUNT_NAME, "test@test.com") + put(Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) + put(Calendars.NAME, "Test Calendar") + put(Calendars.CALENDAR_DISPLAY_NAME, "Test Calendar") + put(Calendars.CALENDAR_COLOR, 0xFF0000) + put(Calendars.CALENDAR_ACCESS_LEVEL, Calendars.CAL_ACCESS_OWNER) + put(Calendars.OWNER_ACCOUNT, "test@test.com") + put(Calendars.VISIBLE, 1) + put(Calendars.SYNC_EVENTS, 1) + } + val uri = Calendars.CONTENT_URI.buildUpon() + .appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") + .appendQueryParameter(Calendars.ACCOUNT_NAME, "test@test.com") + .appendQueryParameter(Calendars.ACCOUNT_TYPE, CalendarContract.ACCOUNT_TYPE_LOCAL) + .build() + val calendarUri = context.contentResolver.insert(uri, values) + return ContentUris.parseId(calendarUri!!) + } + + private fun queryEvent(eventUri: String): CalendarEvent? { + val context = ApplicationProvider.getApplicationContext() + val cursor = context.contentResolver.query( + eventUri.toUri(), + arrayOf( + Events.DTSTART, + Events.DTEND, + Events.ALL_DAY, + Events.EVENT_TIMEZONE + ), + null, + null, + null + ) + return cursor?.use { + if (it.moveToFirst()) { + CalendarEvent( + dtStart = it.getLong(0), + dtEnd = it.getLong(1), + allDay = it.getInt(2) == 1, + timezone = it.getString(3) + ) + } else null + } + } + + private data class CalendarEvent( + val dtStart: Long, + val dtEnd: Long, + val allDay: Boolean, + val timezone: String? + ) +} diff --git a/app/src/main/java/com/todoroo/astrid/gcal/GCalHelper.kt b/app/src/main/java/com/todoroo/astrid/gcal/GCalHelper.kt index 3acf907cf..adfb49d90 100644 --- a/app/src/main/java/com/todoroo/astrid/gcal/GCalHelper.kt +++ b/app/src/main/java/com/todoroo/astrid/gcal/GCalHelper.kt @@ -151,7 +151,7 @@ class GCalHelper @Inject constructor( values.put(CalendarContract.Events.ALL_DAY, "0") values.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) } else { - val utcMidnight = DateTime(dueDate).toUTC().startOfDay() + val utcMidnight = DateTime(dueDate).startOfDay(DateTime.UTC) values.put(CalendarContract.Events.DTSTART, utcMidnight.millis) values.put(CalendarContract.Events.DTEND, utcMidnight.plusDays(1).millis) values.put(CalendarContract.Events.ALL_DAY, "1") diff --git a/app/src/main/java/org/tasks/time/DateTime.kt b/app/src/main/java/org/tasks/time/DateTime.kt index f47b6ca07..399d36668 100644 --- a/app/src/main/java/org/tasks/time/DateTime.kt +++ b/app/src/main/java/org/tasks/time/DateTime.kt @@ -44,7 +44,13 @@ class DateTime { private constructor(calendar: Calendar) : this(calendar.timeInMillis, calendar.timeZone) - fun startOfDay(): DateTime = DateTime(millis.startOfDay()) + @JvmOverloads + fun startOfDay(timeZone: TimeZone = this.timeZone): DateTime = DateTime( + year = year, + month = monthOfYear, + day = dayOfMonth, + timeZone = timeZone + ) fun startOfMinute(): DateTime = DateTime(millis.startOfMinute()) diff --git a/app/src/test/java/org/tasks/time/DateTimeTest.kt b/app/src/test/java/org/tasks/time/DateTimeTest.kt index 80106f763..d3e921b35 100644 --- a/app/src/test/java/org/tasks/time/DateTimeTest.kt +++ b/app/src/test/java/org/tasks/time/DateTimeTest.kt @@ -1,9 +1,12 @@ package org.tasks.time -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.tasks.Freeze import org.tasks.TestUtilities.withTZ +import java.util.TimeZone import java.util.concurrent.TimeUnit class DateTimeTest { @@ -354,4 +357,91 @@ class DateTimeTest { DateTime(2017, 9, 22, 14, 47, 59, 999), DateTime(2017, 9, 22, 14, 47, 14, 453).endOfMinute()) } -} \ No newline at end of file + + @Test + fun startOfDayPreservesTimezone() { + val utcDateTime = DateTime(2024, 12, 20, 14, 30, timeZone = DateTime.UTC) + val result = utcDateTime.startOfDay() + assertEquals(DateTime(2024, 12, 20, timeZone = DateTime.UTC), result) + } + + @Test + fun startOfDayInDefaultTimezone() { + val dateTime = DateTime(2024, 12, 20, 14, 30) + val result = dateTime.startOfDay() + assertEquals(DateTime(2024, 12, 20), result) + } + + @Test + fun startOfDayWithUTCTimezone() { + withTZ("America/Chicago") { // UTC-6 + val utcDateTime = DateTime(2024, 12, 20, 15, timeZone = DateTime.UTC) + val result = utcDateTime.startOfDay() + assertEquals(DateTime(2024, 12, 20, timeZone = DateTime.UTC), result) + } + } + + @Test + fun startOfDayBeforeUTC() { + withTZ("America/New_York") { // UTC-5 + val nyDateTime = DateTime(2024, 12, 20, 15) + val result = nyDateTime.startOfDay() + assertEquals(DateTime(2024, 12, 20), result) + } + } + + @Test + fun startOfDayAfterUTC() { + withTZ("Europe/Berlin") { // UTC+1 + val berlinDateTime = DateTime(2024, 12, 20, 15) + val result = berlinDateTime.startOfDay() + assertEquals(DateTime(2024, 12, 20), result) + } + } + + @Test + fun startOfDayWithDateBoundaryWrap() { + withTZ("Pacific/Auckland") { // UTC+13 + val aucklandDateTime = DateTime(2024, 12, 20, 12) + val result = aucklandDateTime.startOfDay() + assertEquals(DateTime(2024, 12, 20), result) + } + } + + @Test + fun startOfDayRespectsTimezoneNotSystemDefault() { + withTZ("America/New_York") { // UTC-5 + val berlinTz = TimeZone.getTimeZone("Europe/Berlin") // UTC+1 + val berlinDateTime = DateTime(2024, 12, 20, 1, timeZone = berlinTz) + val result = berlinDateTime.startOfDay() + assertEquals(DateTime(2024, 12, 20, timeZone = berlinTz), result) + } + } + + @Test + fun startOfDayWithExplicitTimezone() { + withTZ("Europe/Berlin") { // UTC+1 + val localMidnight = DateTime(2024, 12, 20) + val utcMidnight = localMidnight.startOfDay(DateTime.UTC) + assertEquals(DateTime(2024, 12, 20, timeZone = DateTime.UTC), utcMidnight) + } + } + + @Test + fun startOfDayWithExplicitTimezoneFromAuckland() { + withTZ("Pacific/Auckland") { // UTC+13 + val localMidnight = DateTime(2024, 12, 20) + val utcMidnight = localMidnight.startOfDay(DateTime.UTC) + assertEquals(DateTime(2024, 12, 20, timeZone = DateTime.UTC), utcMidnight) + } + } + + @Test + fun startOfDayWithExplicitTimezoneFromHonolulu() { + withTZ("Pacific/Honolulu") { // UTC-10 + val localMidnight = DateTime(2024, 12, 20) + val utcMidnight = localMidnight.startOfDay(DateTime.UTC) + assertEquals(DateTime(2024, 12, 20, timeZone = DateTime.UTC), utcMidnight) + } + } +}