1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.dialer.calllogutils;
18 
19 import android.content.Context;
20 import android.icu.lang.UCharacter;
21 import android.icu.text.BreakIterator;
22 import android.text.format.DateUtils;
23 import java.util.Calendar;
24 import java.util.Locale;
25 import java.util.concurrent.TimeUnit;
26 
27 /** Static methods for formatting dates in the call log. */
28 public final class CallLogDates {
29 
30   /**
31    * Uses the new date formatting rules to format dates in the new call log.
32    *
33    * <p>Rules:
34    *
35    * <pre>
36    *   if < 1 minute ago: "Just now";
37    *   else if < 1 hour ago: time relative to now (e.g., "8 min ago");
38    *   else if today: time (e.g., "12:15 PM");
39    *   else if < 7 days: day of week (e.g., "Wed");
40    *   else if < 1 year: date with month, day, but no year (e.g., "Jan 15");
41    *   else: date with month, day, and year (e.g., "Jan 15, 2018").
42    * </pre>
43    *
44    * <p>Callers can decide whether to abbreviate date/time by specifying flag {@code
45    * abbreviateDateTime}.
46    */
newCallLogTimestampLabel( Context context, long nowMillis, long timestampMillis, boolean abbreviateDateTime)47   public static CharSequence newCallLogTimestampLabel(
48       Context context, long nowMillis, long timestampMillis, boolean abbreviateDateTime) {
49     // For calls logged less than 1 minute ago, display "Just now".
50     if (nowMillis - timestampMillis < TimeUnit.MINUTES.toMillis(1)) {
51       return context.getString(R.string.just_now);
52     }
53 
54     // For calls logged less than 1 hour ago, display time relative to now (e.g., "8 min ago").
55     if (nowMillis - timestampMillis < TimeUnit.HOURS.toMillis(1)) {
56       return abbreviateDateTime
57           ? DateUtils.getRelativeTimeSpanString(
58                   timestampMillis,
59                   nowMillis,
60                   DateUtils.MINUTE_IN_MILLIS,
61                   DateUtils.FORMAT_ABBREV_RELATIVE)
62               .toString()
63               // The platform method DateUtils#getRelativeTimeSpanString adds a dot ('.') after the
64               // abbreviated time unit for some languages (e.g., "8 min. ago") but we prefer not to
65               // have the dot.
66               .replace(".", "")
67           : DateUtils.getRelativeTimeSpanString(
68               timestampMillis, nowMillis, DateUtils.MINUTE_IN_MILLIS);
69     }
70 
71     int dayDifference = getDayDifference(nowMillis, timestampMillis);
72 
73     // For calls logged today, display time (e.g., "12:15 PM").
74     if (dayDifference == 0) {
75       return DateUtils.formatDateTime(context, timestampMillis, DateUtils.FORMAT_SHOW_TIME);
76     }
77 
78     // For calls logged within a week, display the day of week (e.g., "Wed").
79     if (dayDifference < 7) {
80       return formatDayOfWeek(context, timestampMillis, abbreviateDateTime);
81     }
82 
83     // For calls logged within a year, display month, day, but no year (e.g., "Jan 15").
84     if (isWithinOneYear(nowMillis, timestampMillis)) {
85       return formatDate(context, timestampMillis, /* showYear = */ false, abbreviateDateTime);
86     }
87 
88     // For calls logged no less than one year ago, display month, day, and year
89     // (e.g., "Jan 15, 2018").
90     return formatDate(context, timestampMillis, /* showYear = */ true, abbreviateDateTime);
91   }
92 
93   /**
94    * Formats the provided timestamp (in milliseconds) into date and time suitable for display in the
95    * current locale.
96    *
97    * <p>For example, returns a string like "Wednesday, May 25, 2016, 8:02PM" or "Chorshanba, 2016
98    * may 25,20:02".
99    *
100    * <p>For pre-N devices, the returned value may not start with a capital if the local convention
101    * is to not capitalize day names. On N+ devices, the returned value is always capitalized.
102    */
formatDate(Context context, long timestamp)103   public static CharSequence formatDate(Context context, long timestamp) {
104     return toTitleCase(
105         DateUtils.formatDateTime(
106             context,
107             timestamp,
108             DateUtils.FORMAT_SHOW_TIME
109                 | DateUtils.FORMAT_SHOW_DATE
110                 | DateUtils.FORMAT_SHOW_WEEKDAY
111                 | DateUtils.FORMAT_SHOW_YEAR));
112   }
113 
114   /**
115    * Formats the provided timestamp (in milliseconds) into the month, day, and optionally, year.
116    *
117    * <p>For example, returns a string like "Jan 15" or "Jan 15, 2018".
118    *
119    * <p>For pre-N devices, the returned value may not start with a capital if the local convention
120    * is to not capitalize day names. On N+ devices, the returned value is always capitalized.
121    */
formatDate( Context context, long timestamp, boolean showYear, boolean abbreviateDateTime)122   private static CharSequence formatDate(
123       Context context, long timestamp, boolean showYear, boolean abbreviateDateTime) {
124     int formatFlags = 0;
125     if (abbreviateDateTime) {
126       formatFlags |= DateUtils.FORMAT_ABBREV_MONTH;
127     }
128     if (!showYear) {
129       formatFlags |= DateUtils.FORMAT_NO_YEAR;
130     }
131 
132     return toTitleCase(DateUtils.formatDateTime(context, timestamp, formatFlags));
133   }
134 
135   /**
136    * Formats the provided timestamp (in milliseconds) into day of week.
137    *
138    * <p>For example, returns a string like "Wed" or "Chor".
139    *
140    * <p>For pre-N devices, the returned value may not start with a capital if the local convention
141    * is to not capitalize day names. On N+ devices, the returned value is always capitalized.
142    */
formatDayOfWeek( Context context, long timestamp, boolean abbreviateDateTime)143   private static CharSequence formatDayOfWeek(
144       Context context, long timestamp, boolean abbreviateDateTime) {
145     int formatFlags =
146         abbreviateDateTime
147             ? (DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_WEEKDAY)
148             : DateUtils.FORMAT_SHOW_WEEKDAY;
149     return toTitleCase(DateUtils.formatDateTime(context, timestamp, formatFlags));
150   }
151 
toTitleCase(CharSequence value)152   private static CharSequence toTitleCase(CharSequence value) {
153     // We want the beginning of the date string to be capitalized, even if the word at the beginning
154     // of the string is not usually capitalized. For example, "Wednesdsay" in Uzbek is "chorshanba”
155     // (not capitalized). To handle this issue we apply title casing to the start of the sentence so
156     // that "chorshanba, 2016 may 25,20:02" becomes "Chorshanba, 2016 may 25,20:02".
157 
158     // Using the ICU library is safer than just applying toUpperCase() on the first letter of the
159     // word because in some languages, there can be multiple starting characters which should be
160     // upper-cased together. For example in Dutch "ij" is a digraph in which both letters should be
161     // capitalized together.
162 
163     // TITLECASE_NO_LOWERCASE is necessary so that things that are already capitalized are not
164     // lower-cased as part of the conversion.
165     return UCharacter.toTitleCase(
166         Locale.getDefault(),
167         value.toString(),
168         BreakIterator.getSentenceInstance(),
169         UCharacter.TITLECASE_NO_LOWERCASE);
170   }
171 
172   /**
173    * Returns the absolute difference in days between two timestamps. It is the caller's
174    * responsibility to ensure both timestamps are in milliseconds. Failure to do so will result in
175    * undefined behavior.
176    *
177    * <p>Note that the difference is based on day boundaries, not 24-hour periods.
178    *
179    * <p>Examples:
180    *
181    * <ul>
182    *   <li>The difference between 01/19/2018 00:00 and 01/19/2018 23:59 is 0.
183    *   <li>The difference between 01/18/2018 23:59 and 01/19/2018 23:59 is 1.
184    *   <li>The difference between 01/18/2018 00:00 and 01/19/2018 23:59 is 1.
185    *   <li>The difference between 01/17/2018 23:59 and 01/19/2018 00:00 is 2.
186    * </ul>
187    */
getDayDifference(long firstTimestamp, long secondTimestamp)188   public static int getDayDifference(long firstTimestamp, long secondTimestamp) {
189     // Ensure secondTimestamp is no less than firstTimestamp
190     if (secondTimestamp < firstTimestamp) {
191       long t = firstTimestamp;
192       firstTimestamp = secondTimestamp;
193       secondTimestamp = t;
194     }
195 
196     // Use secondTimestamp as reference
197     Calendar startOfReferenceDay = Calendar.getInstance();
198     startOfReferenceDay.setTimeInMillis(secondTimestamp);
199 
200     // This is attempting to find the start of the reference day, but it's not quite right due to
201     // daylight savings. Unfortunately there doesn't seem to be a way to get the correct start of
202     // the day without using Joda or Java8, both of which are disallowed. This means that the wrong
203     // formatting may be applied on days with time changes (though the displayed values will be
204     // correct).
205     startOfReferenceDay.add(Calendar.HOUR_OF_DAY, -startOfReferenceDay.get(Calendar.HOUR_OF_DAY));
206     startOfReferenceDay.add(Calendar.MINUTE, -startOfReferenceDay.get(Calendar.MINUTE));
207     startOfReferenceDay.add(Calendar.SECOND, -startOfReferenceDay.get(Calendar.SECOND));
208     startOfReferenceDay.add(Calendar.MILLISECOND, -startOfReferenceDay.get(Calendar.MILLISECOND));
209 
210     Calendar other = Calendar.getInstance();
211     other.setTimeInMillis(firstTimestamp);
212 
213     int dayDifference = 0;
214     while (other.before(startOfReferenceDay)) {
215       startOfReferenceDay.add(Calendar.DATE, -1);
216       dayDifference++;
217     }
218 
219     return dayDifference;
220   }
221 
222   /**
223    * Returns true if the two timestamps are within one year. It is the caller's responsibility to
224    * ensure both timestamps are in milliseconds. Failure to do so will result in undefined behavior.
225    *
226    * <p>Note that the difference is based on 365/366-day periods.
227    *
228    * <p>Examples:
229    *
230    * <ul>
231    *   <li>01/01/2018 00:00 and 12/31/2018 23:59 is within one year.
232    *   <li>12/31/2017 23:59 and 12/31/2018 23:59 is not within one year.
233    *   <li>12/31/2017 23:59 and 01/01/2018 00:00 is within one year.
234    * </ul>
235    */
isWithinOneYear(long firstTimestamp, long secondTimestamp)236   private static boolean isWithinOneYear(long firstTimestamp, long secondTimestamp) {
237     // Ensure secondTimestamp is no less than firstTimestamp
238     if (secondTimestamp < firstTimestamp) {
239       long t = firstTimestamp;
240       firstTimestamp = secondTimestamp;
241       secondTimestamp = t;
242     }
243 
244     // Use secondTimestamp as reference
245     Calendar reference = Calendar.getInstance();
246     reference.setTimeInMillis(secondTimestamp);
247     reference.add(Calendar.YEAR, -1);
248 
249     Calendar other = Calendar.getInstance();
250     other.setTimeInMillis(firstTimestamp);
251 
252     return reference.before(other);
253   }
254 }
255