Welcome to mirror list, hosted at ThFree Co, Russian Federation.

AndroidTimeUtils.kt « util « ical4android « bitfire « at « java « main « src - github.com/bitfireAT/ical4android.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: 9d22cd85d7710544f264c46bdb23c939dfe6f760 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
/***************************************************************************************************
 * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
 **************************************************************************************************/

@file:Suppress("DEPRECATION")

package at.bitfire.ical4android.util

import android.text.format.Time
import at.bitfire.ical4android.DateUtils
import at.bitfire.ical4android.Ical4Android
import net.fortuna.ical4j.model.*
import net.fortuna.ical4j.model.Date
import net.fortuna.ical4j.model.TimeZone
import net.fortuna.ical4j.model.parameter.Value
import net.fortuna.ical4j.model.property.DateListProperty
import net.fortuna.ical4j.model.property.DateProperty
import net.fortuna.ical4j.model.property.ExDate
import net.fortuna.ical4j.model.property.RDate
import net.fortuna.ical4j.util.TimeZones
import java.text.ParseException
import java.text.SimpleDateFormat
import java.time.Duration
import java.time.Period
import java.time.temporal.TemporalAmount
import java.util.*

object AndroidTimeUtils {

    /**
     * Timezone ID to store for all-day events, according to CalendarContract.Events SDK documentation.
     */
    @Suppress("DEPRECATION")
    val TZID_ALLDAY = Time.TIMEZONE_UTC

    private const val RECURRENCE_LIST_TZID_SEPARATOR = ';'
    private const val RECURRENCE_LIST_VALUE_SEPARATOR = ","

    /**
     * Used to separate multiple RRULEs/EXRULEs in the RRULE/EXRULE storage field.
     */
    const val RECURRENCE_RULE_SEPARATOR = "\n"


    /**
     * Ensures that a given [DateProperty] either
     *
     * 1. has a time zone with an ID that is available in Android, or
     * 2. is an UTC property ([DateProperty.isUtc] = *true*).
     *
     * To get the time zone ID which shall be given to the Calendar provider,
     * use [storageTzId].
     *
     * @param date [DateProperty] to validate. Values which are not DATE-TIME will be ignored.
     */
    fun androidifyTimeZone(date: DateProperty?) {
        if (DateUtils.isDateTime(date) && date?.isUtc == false) {
            val tzID = date.timeZone?.id
            date.timeZone = bestMatchingTzId(tzID)
        }
    }

    /**
     * Ensures that a given [DateListProperty] either
     *
     * 1. has a time zone with an ID that is available in Android, or
     * 2. is an UTC property ([DateProperty.isUtc] = *true*).
     * *
     * @param dateList [DateListProperty] to validate. Values which are not DATE-TIME will be ignored.
     */
    fun androidifyTimeZone(dateList: DateListProperty) {
        // periods (RDate only)
        val periods = (dateList as? RDate)?.periods
        if (periods != null && periods.size > 0 && !periods.isUtc) {
            val tzID = periods.timeZone?.id

            // Won't work until resolved in ical4j (https://github.com/ical4j/ical4j/discussions/568)
            // DateListProperty.setTimeZone() does not set the timeZone property when the DateList has PERIODs
            dateList.timeZone = bestMatchingTzId(tzID)

            return //  RDate can only contain periods OR dates - not both, bail out fast
        }

        // date-times (RDate and ExDate)
        val dates = dateList.dates
        if (dates != null && dates.size > 0) {
            if (dates.type == Value.DATE_TIME && !dates.isUtc) {
                val tzID = dates.timeZone?.id
                dateList.timeZone = bestMatchingTzId(tzID)
            }
        }
    }

    private fun bestMatchingTzId(tzID: String?): TimeZone? {
        val bestMatchingTzId = DateUtils.findAndroidTimezoneID(tzID)
        return if (tzID == bestMatchingTzId) {
            DateUtils.ical4jTimeZone(tzID)
        } else {
            Ical4Android.log.warning("Android doesn't know time zone ${tzID ?: "\"null\" (floating)"}, setting default time zone $bestMatchingTzId")
            DateUtils.ical4jTimeZone(bestMatchingTzId)
        }
    }

    /**
     * Returns the time-zone ID for a given date or date-time that should be used to store it
     * in the Android calendar provider.
     *
     * @param date DateProperty (DATE or DATE-TIME) whose time-zone information is used
     *
     * @return - UTC for dates and UTC date-times
     *         - the specified time zone ID for date-times with given time zone
     *         - the currently set default time zone ID for floating date-times
     */
    fun storageTzId(date: DateProperty): String =
            if (DateUtils.isDateTime(date)) {
                // DATE-TIME
                when {
                    date.isUtc ->
                        // DATE-TIME in UTC format
                        TimeZones.UTC_ID
                    date.timeZone != null ->
                        // DATE-TIME with given time-zone
                        date.timeZone.id
                    else ->
                        // DATE-TIME in local format (floating)
                        java.util.TimeZone.getDefault().id
                }
            } else
                // DATE
                TZID_ALLDAY


    // recurrence sets

    /**
     * Concatenates, if necessary, multiple RDATE/EXDATE lists and converts them to
     * a formatted string which Android calendar provider can process.
     *
     * Android expects this format: "[TZID;]date1,date2,date3" where date is "yyyymmddThhmmss" (when
     * TZID is given) or "yyyymmddThhmmssZ". We don't use the TZID format here because then we're limited
     * to one time-zone, while an iCalendar may contain multiple EXDATE/RDATE lines with different time zones.
     *
     * @param dates         one more more lists of RDATE or EXDATE
     * @param allDay        whether the event is an all-day event or not
     *
     * @return formatted string for Android calendar provider
     */
    fun recurrenceSetsToAndroidString(dates: List<DateListProperty>, allDay: Boolean): String {
        /*  rdate/exdate:       DATE                                DATE_TIME
            all-day             store as ...T000000Z                cut off time and store as ...T000000Z
            event with time     (undefined)                         store as ...ThhmmssZ
        */
        val dateFormatUtcMidnight = SimpleDateFormat("yyyyMMdd'T'000000'Z'", Locale.ROOT)
        val strDates = LinkedList<String>()

        // use time zone of first entry for the whole set; null for UTC
        val tz =
            (dates.firstOrNull() as? RDate)?.periods?.timeZone ?:   // VALUE=PERIOD (only RDate)
            dates.firstOrNull()?.dates?.timeZone                    // VALUE=DATE/DATE-TIME

        for (dateListProp in dates) {
            if (dateListProp is RDate && dateListProp.periods.isNotEmpty()) {
                Ical4Android.log.warning("RDATE PERIOD not supported, ignoring")
                break
            }

            when (dateListProp.dates.type) {
                Value.DATE_TIME -> {
                    if (tz == null && !dateListProp.dates.isUtc)
                        dateListProp.setUtc(true)
                    else if (tz != null && dateListProp.timeZone != tz)
                        dateListProp.timeZone = tz

                    if (allDay)
                        dateListProp.dates.mapTo(strDates) { dateFormatUtcMidnight.format(it) }
                    else
                        strDates.add(dateListProp.value)
                }
                Value.DATE ->
                    // DATE values have to be converted to DATE-TIME <date>T000000Z for Android
                    dateListProp.dates.mapTo(strDates) {
                        dateFormatUtcMidnight.format(it)
                    }
            }
        }

        // format: [tzid;]value1,value2,...
        val result = StringBuilder()
        if (tz != null)
            result.append(tz.id).append(RECURRENCE_LIST_TZID_SEPARATOR)
        result.append(strDates.joinToString(RECURRENCE_LIST_VALUE_SEPARATOR))
        return result.toString()
    }

    /**
     * Takes a formatted string as provided by the Android calendar provider and returns a DateListProperty
     * constructed from these values.
     *
     * @param dbStr     formatted string from Android calendar provider (RDATE/EXDATE field)
     *                  expected format: "[TZID;]date1,date2,date3" where date is "yyyymmddThhmmss[Z]"
     * @param allDay    true: list will contain DATE values; false: list will contain DATE_TIME values
     * @param exclude   this time stamp won't be added to the [DateListProperty]
     * @param generator generates the [DateListProperty]; must call the constructor with the one argument of type [DateList]
     *
     * @return          instance of "type" containing the parsed dates/times from the string
     *
     * @throws ParseException when the string cannot be parsed
     */
    fun<T: DateListProperty> androidStringToRecurrenceSet(dbStr: String, allDay: Boolean, exclude: Long? = null, generator: (DateList) -> T): T
    {
        // 1. split string into time zone and actual dates
        var timeZone: TimeZone?
        val datesStr: String

        val limiter = dbStr.indexOf(RECURRENCE_LIST_TZID_SEPARATOR)
        if (limiter != -1) {    // TZID given
            val tzId = dbStr.substring(0, limiter)
            timeZone = DateUtils.ical4jTimeZone(tzId)
            if (TimeZones.isUtc(timeZone))
                timeZone = null
            datesStr = dbStr.substring(limiter + 1)
        } else {
            timeZone = null
            datesStr = dbStr
        }

        // 2. process date string and generate list of DATEs or DATE-TIMEs
        val dateList =
                if (allDay)
                    DateList(datesStr, Value.DATE)
                else
                    DateList(datesStr, Value.DATE_TIME, timeZone)

        // 3. filter excludes
        val iter = dateList.iterator()
        while (iter.hasNext()) {
            val date = iter.next()
            if (date.time == exclude)
                iter.remove()
        }

        // 4. generate requested DateListProperty (RDate/ExDate) from list of DATEs or DATE-TIMEs
        val property = generator(dateList)
        if (!allDay) {
            if (timeZone != null)
                property.timeZone = timeZone
            else
                property.setUtc(true)
        }

        return property
    }

    /**
     * Concatenates, if necessary, multiple RDATE/EXDATE lists and converts them to
     * a formatted string which OpenTasks can process.
     * OpenTasks expect a list of RFC 5545 DATE ("yyyymmdd") or DATE-TIME ("yyyymmdd[Z]") values,
     * where the time zone is stored in a separate field.
     *
     * @param dates         one more more lists of RDATE or EXDATE
     * @param tz            output time zone (*null* for all-day event)
     *
     * @return formatted string for Android calendar provider
     */
    fun recurrenceSetsToOpenTasksString(dates: List<DateListProperty>, tz: TimeZone?): String {
        val allDay = tz == null
        val strDates = LinkedList<String>()
        for (dateListProp in dates) {
            if (dateListProp is RDate)
                if (dateListProp.periods.isNotEmpty())
                    Ical4Android.log.warning("RDATE PERIOD not supported, ignoring")
                else if (dateListProp is ExDate)
                    if (dateListProp.periods.isNotEmpty())
                        Ical4Android.log.warning("EXDATE PERIOD not supported, ignoring")

            for (date in dateListProp.dates) {
                val dateToUse =
                        if (date is DateTime && allDay)             // VALUE=DATE-TIME, but allDay=1
                            Date(date)
                        else if (date !is DateTime && !allDay)      // VALUE=DATE, but allDay=0
                            DateTime(date.toString(), tz)
                        else
                            date
                if (dateToUse is DateTime && !dateToUse.isUtc)
                    dateToUse.timeZone = tz!!
                strDates += dateToUse.toString()
            }
        }
        return strDates.joinToString(RECURRENCE_LIST_VALUE_SEPARATOR)
    }


    // duration

    /**
     * Checks and fixes [Event.duration] values with incorrect format which can't be
     * parsed by ical4j. Searches for values like "1H" and "3M" and
     * groups them together in a standards-compliant way.
     *
     * @param durationStr value from the content provider (like "PT3600S" or "P3600S")
     * @return duration value in RFC 2445 format ("PT3600S" when the argument was "P3600S")
     */
    fun parseDuration(durationStr: String): TemporalAmount {
        /** [RFC 2445/5445]
         * dur-value  = (["+"] / "-") "P" (dur-date / dur-time / dur-week)
         * dur-date   = dur-day [dur-time]
         * dur-day    = 1*DIGIT "D"
         * dur-time   = "T" (dur-hour / dur-minute / dur-second)
         * dur-week   = 1*DIGIT "W"
         * dur-hour   = 1*DIGIT "H" [dur-minute]
         * dur-minute = 1*DIGIT "M" [dur-second]
         * dur-second = 1*DIGIT "S"
         */
        val possibleFormats = Regex("([+-]?)P?(T|((\\d+)W)|((\\d+)D)|((\\d+)H)|((\\d+)M)|((\\d+)S))*")
                                         //  1            4         6         8         10        12
        possibleFormats.matchEntire(durationStr)?.let { result ->
            fun fromMatch(s: String) = if (s.isEmpty()) 0 else s.toInt()

            val intSign = if (result.groupValues[1] == "-") -1 else 1
            val intDays = fromMatch(result.groupValues[4]) * TimeApiExtensions.DAYS_PER_WEEK + fromMatch(result.groupValues[6])
            val intHours = fromMatch(result.groupValues[8])
            val intMinutes = fromMatch(result.groupValues[10])
            val intSeconds = fromMatch(result.groupValues[12])

            return if (intDays != 0 && intHours == 0 && intMinutes == 0 && intSeconds == 0)
                Period.ofDays(intSign * intDays)
            else
                Duration.ofSeconds(intSign * (
                        intDays * TimeApiExtensions.SECONDS_PER_DAY.toLong() +
                        intHours * TimeApiExtensions.SECONDS_PER_HOUR +
                        intMinutes * TimeApiExtensions.SECONDS_PER_MINUTE +
                        intSeconds
                ))
        }
        // no match, try TemporalAmountAdapter
        return TemporalAmountAdapter.parse(durationStr).duration
    }

}