1 /* 2 * Copyright (C) 2010 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 android.widget; 18 19 import static android.text.format.DateUtils.DAY_IN_MILLIS; 20 import static android.text.format.DateUtils.HOUR_IN_MILLIS; 21 import static android.text.format.DateUtils.MINUTE_IN_MILLIS; 22 import static android.text.format.DateUtils.YEAR_IN_MILLIS; 23 24 import android.app.ActivityThread; 25 import android.compat.annotation.UnsupportedAppUsage; 26 import android.content.BroadcastReceiver; 27 import android.content.Context; 28 import android.content.Intent; 29 import android.content.IntentFilter; 30 import android.content.res.Configuration; 31 import android.content.res.TypedArray; 32 import android.database.ContentObserver; 33 import android.os.Handler; 34 import android.util.AttributeSet; 35 import android.view.accessibility.AccessibilityNodeInfo; 36 import android.view.inspector.InspectableProperty; 37 import android.widget.RemoteViews.RemoteView; 38 39 import com.android.internal.R; 40 41 import java.text.DateFormat; 42 import java.time.Instant; 43 import java.time.LocalDate; 44 import java.time.LocalDateTime; 45 import java.time.LocalTime; 46 import java.time.ZoneId; 47 import java.time.temporal.JulianFields; 48 import java.util.ArrayList; 49 import java.util.Date; 50 51 // 52 // TODO 53 // - listen for the next threshold time to update the view. 54 // - listen for date format pref changed 55 // - put the AM/PM in a smaller font 56 // 57 58 /** 59 * Displays a given time in a convenient human-readable foramt. 60 * 61 * @hide 62 */ 63 @RemoteView 64 public class DateTimeView extends TextView { 65 private static final int SHOW_TIME = 0; 66 private static final int SHOW_MONTH_DAY_YEAR = 1; 67 68 private long mTimeMillis; 69 // The LocalDateTime equivalent of mTimeMillis but truncated to minute, i.e. no seconds / nanos. 70 private LocalDateTime mLocalTime; 71 72 int mLastDisplay = -1; 73 DateFormat mLastFormat; 74 75 private long mUpdateTimeMillis; 76 private static final ThreadLocal<ReceiverInfo> sReceiverInfo = new ThreadLocal<ReceiverInfo>(); 77 private String mNowText; 78 private boolean mShowRelativeTime; 79 DateTimeView(Context context)80 public DateTimeView(Context context) { 81 this(context, null); 82 } 83 84 @UnsupportedAppUsage DateTimeView(Context context, AttributeSet attrs)85 public DateTimeView(Context context, AttributeSet attrs) { 86 super(context, attrs); 87 final TypedArray a = context.obtainStyledAttributes(attrs, 88 com.android.internal.R.styleable.DateTimeView, 0, 89 0); 90 91 final int N = a.getIndexCount(); 92 for (int i = 0; i < N; i++) { 93 int attr = a.getIndex(i); 94 switch (attr) { 95 case R.styleable.DateTimeView_showRelative: 96 boolean relative = a.getBoolean(i, false); 97 setShowRelativeTime(relative); 98 break; 99 } 100 } 101 a.recycle(); 102 } 103 104 @Override onAttachedToWindow()105 protected void onAttachedToWindow() { 106 super.onAttachedToWindow(); 107 ReceiverInfo ri = sReceiverInfo.get(); 108 if (ri == null) { 109 ri = new ReceiverInfo(); 110 sReceiverInfo.set(ri); 111 } 112 ri.addView(this); 113 // The view may not be added to the view hierarchy immediately right after setTime() 114 // is called which means it won't get any update from intents before being added. 115 // In such case, the view might show the incorrect relative time after being added to the 116 // view hierarchy until the next update intent comes. 117 // So we update the time here if mShowRelativeTime is enabled to prevent this case. 118 if (mShowRelativeTime) { 119 update(); 120 } 121 } 122 123 @Override onDetachedFromWindow()124 protected void onDetachedFromWindow() { 125 super.onDetachedFromWindow(); 126 final ReceiverInfo ri = sReceiverInfo.get(); 127 if (ri != null) { 128 ri.removeView(this); 129 } 130 } 131 132 @android.view.RemotableViewMethod 133 @UnsupportedAppUsage setTime(long timeMillis)134 public void setTime(long timeMillis) { 135 mTimeMillis = timeMillis; 136 LocalDateTime dateTime = toLocalDateTime(timeMillis, ZoneId.systemDefault()); 137 mLocalTime = dateTime.withSecond(0); 138 update(); 139 } 140 141 @android.view.RemotableViewMethod setShowRelativeTime(boolean showRelativeTime)142 public void setShowRelativeTime(boolean showRelativeTime) { 143 mShowRelativeTime = showRelativeTime; 144 updateNowText(); 145 update(); 146 } 147 148 /** 149 * Returns whether this view shows relative time 150 * 151 * @return True if it shows relative time, false otherwise 152 */ 153 @InspectableProperty(name = "showReleative", hasAttributeId = false) isShowRelativeTime()154 public boolean isShowRelativeTime() { 155 return mShowRelativeTime; 156 } 157 158 @Override 159 @android.view.RemotableViewMethod setVisibility(@isibility int visibility)160 public void setVisibility(@Visibility int visibility) { 161 boolean gotVisible = visibility != GONE && getVisibility() == GONE; 162 super.setVisibility(visibility); 163 if (gotVisible) { 164 update(); 165 } 166 } 167 168 @UnsupportedAppUsage update()169 void update() { 170 if (mLocalTime == null || getVisibility() == GONE) { 171 return; 172 } 173 if (mShowRelativeTime) { 174 updateRelativeTime(); 175 return; 176 } 177 178 int display; 179 ZoneId zoneId = ZoneId.systemDefault(); 180 181 // localTime is the local time for mTimeMillis but at zero seconds past the minute. 182 LocalDateTime localTime = mLocalTime; 183 LocalDateTime localStartOfDay = 184 LocalDateTime.of(localTime.toLocalDate(), LocalTime.MIDNIGHT); 185 LocalDateTime localTomorrowStartOfDay = localStartOfDay.plusDays(1); 186 // now is current local time but at zero seconds past the minute. 187 LocalDateTime localNow = LocalDateTime.now(zoneId).withSecond(0); 188 189 long twelveHoursBefore = toEpochMillis(localTime.minusHours(12), zoneId); 190 long twelveHoursAfter = toEpochMillis(localTime.plusHours(12), zoneId); 191 long midnightBefore = toEpochMillis(localStartOfDay, zoneId); 192 long midnightAfter = toEpochMillis(localTomorrowStartOfDay, zoneId); 193 long time = toEpochMillis(localTime, zoneId); 194 long now = toEpochMillis(localNow, zoneId); 195 196 // Choose the display mode 197 choose_display: { 198 if ((now >= midnightBefore && now < midnightAfter) 199 || (now >= twelveHoursBefore && now < twelveHoursAfter)) { 200 display = SHOW_TIME; 201 break choose_display; 202 } 203 // Else, show month day and year. 204 display = SHOW_MONTH_DAY_YEAR; 205 break choose_display; 206 } 207 208 // Choose the format 209 DateFormat format; 210 if (display == mLastDisplay && mLastFormat != null) { 211 // use cached format 212 format = mLastFormat; 213 } else { 214 switch (display) { 215 case SHOW_TIME: 216 format = getTimeFormat(); 217 break; 218 case SHOW_MONTH_DAY_YEAR: 219 format = DateFormat.getDateInstance(DateFormat.SHORT); 220 break; 221 default: 222 throw new RuntimeException("unknown display value: " + display); 223 } 224 mLastFormat = format; 225 } 226 227 // Set the text 228 String text = format.format(new Date(time)); 229 setText(text); 230 231 // Schedule the next update 232 if (display == SHOW_TIME) { 233 // Currently showing the time, update at the later of twelve hours after or midnight. 234 mUpdateTimeMillis = twelveHoursAfter > midnightAfter ? twelveHoursAfter : midnightAfter; 235 } else { 236 // Currently showing the date 237 if (mTimeMillis < now) { 238 // If the time is in the past, don't schedule an update 239 mUpdateTimeMillis = 0; 240 } else { 241 // If hte time is in the future, schedule one at the earlier of twelve hours 242 // before or midnight before. 243 mUpdateTimeMillis = twelveHoursBefore < midnightBefore 244 ? twelveHoursBefore : midnightBefore; 245 } 246 } 247 } 248 249 private void updateRelativeTime() { 250 long now = System.currentTimeMillis(); 251 long duration = Math.abs(now - mTimeMillis); 252 int count; 253 long millisIncrease; 254 boolean past = (now >= mTimeMillis); 255 String result; 256 if (duration < MINUTE_IN_MILLIS) { 257 setText(mNowText); 258 mUpdateTimeMillis = mTimeMillis + MINUTE_IN_MILLIS + 1; 259 return; 260 } else if (duration < HOUR_IN_MILLIS) { 261 count = (int)(duration / MINUTE_IN_MILLIS); 262 result = String.format(getContext().getResources().getQuantityString(past 263 ? com.android.internal.R.plurals.duration_minutes_shortest 264 : com.android.internal.R.plurals.duration_minutes_shortest_future, 265 count), 266 count); 267 millisIncrease = MINUTE_IN_MILLIS; 268 } else if (duration < DAY_IN_MILLIS) { 269 count = (int)(duration / HOUR_IN_MILLIS); 270 result = String.format(getContext().getResources().getQuantityString(past 271 ? com.android.internal.R.plurals.duration_hours_shortest 272 : com.android.internal.R.plurals.duration_hours_shortest_future, 273 count), 274 count); 275 millisIncrease = HOUR_IN_MILLIS; 276 } else if (duration < YEAR_IN_MILLIS) { 277 // In weird cases it can become 0 because of daylight savings 278 LocalDateTime localDateTime = mLocalTime; 279 ZoneId zoneId = ZoneId.systemDefault(); 280 LocalDateTime localNow = toLocalDateTime(now, zoneId); 281 282 count = Math.max(Math.abs(dayDistance(localDateTime, localNow)), 1); 283 result = String.format(getContext().getResources().getQuantityString(past 284 ? com.android.internal.R.plurals.duration_days_shortest 285 : com.android.internal.R.plurals.duration_days_shortest_future, 286 count), 287 count); 288 if (past || count != 1) { 289 mUpdateTimeMillis = computeNextMidnight(localNow, zoneId); 290 millisIncrease = -1; 291 } else { 292 millisIncrease = DAY_IN_MILLIS; 293 } 294 295 } else { 296 count = (int)(duration / YEAR_IN_MILLIS); 297 result = String.format(getContext().getResources().getQuantityString(past 298 ? com.android.internal.R.plurals.duration_years_shortest 299 : com.android.internal.R.plurals.duration_years_shortest_future, 300 count), 301 count); 302 millisIncrease = YEAR_IN_MILLIS; 303 } 304 if (millisIncrease != -1) { 305 if (past) { 306 mUpdateTimeMillis = mTimeMillis + millisIncrease * (count + 1) + 1; 307 } else { 308 mUpdateTimeMillis = mTimeMillis - millisIncrease * count + 1; 309 } 310 } 311 setText(result); 312 } 313 314 /** 315 * Returns the epoch millis for the next midnight in the specified timezone. 316 */ computeNextMidnight(LocalDateTime time, ZoneId zoneId)317 private static long computeNextMidnight(LocalDateTime time, ZoneId zoneId) { 318 // This ignores the chance of overflow: it should never happen. 319 LocalDate tomorrow = time.toLocalDate().plusDays(1); 320 LocalDateTime nextMidnight = LocalDateTime.of(tomorrow, LocalTime.MIDNIGHT); 321 return toEpochMillis(nextMidnight, zoneId); 322 } 323 324 @Override onConfigurationChanged(Configuration newConfig)325 protected void onConfigurationChanged(Configuration newConfig) { 326 super.onConfigurationChanged(newConfig); 327 updateNowText(); 328 update(); 329 } 330 updateNowText()331 private void updateNowText() { 332 if (!mShowRelativeTime) { 333 return; 334 } 335 mNowText = getContext().getResources().getString( 336 com.android.internal.R.string.now_string_shortest); 337 } 338 339 // Return the number of days between the two dates. dayDistance(LocalDateTime start, LocalDateTime end)340 private static int dayDistance(LocalDateTime start, LocalDateTime end) { 341 return (int) (end.getLong(JulianFields.JULIAN_DAY) 342 - start.getLong(JulianFields.JULIAN_DAY)); 343 } 344 getTimeFormat()345 private DateFormat getTimeFormat() { 346 return android.text.format.DateFormat.getTimeFormat(getContext()); 347 } 348 clearFormatAndUpdate()349 void clearFormatAndUpdate() { 350 mLastFormat = null; 351 update(); 352 } 353 354 @Override onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info)355 public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { 356 super.onInitializeAccessibilityNodeInfoInternal(info); 357 if (mShowRelativeTime) { 358 // The short version of the time might not be completely understandable and for 359 // accessibility we rather have a longer version. 360 long now = System.currentTimeMillis(); 361 long duration = Math.abs(now - mTimeMillis); 362 int count; 363 boolean past = (now >= mTimeMillis); 364 String result; 365 if (duration < MINUTE_IN_MILLIS) { 366 result = mNowText; 367 } else if (duration < HOUR_IN_MILLIS) { 368 count = (int)(duration / MINUTE_IN_MILLIS); 369 result = String.format(getContext().getResources().getQuantityString(past 370 ? com.android.internal. 371 R.plurals.duration_minutes_relative 372 : com.android.internal. 373 R.plurals.duration_minutes_relative_future, 374 count), 375 count); 376 } else if (duration < DAY_IN_MILLIS) { 377 count = (int)(duration / HOUR_IN_MILLIS); 378 result = String.format(getContext().getResources().getQuantityString(past 379 ? com.android.internal. 380 R.plurals.duration_hours_relative 381 : com.android.internal. 382 R.plurals.duration_hours_relative_future, 383 count), 384 count); 385 } else if (duration < YEAR_IN_MILLIS) { 386 // In weird cases it can become 0 because of daylight savings 387 LocalDateTime localDateTime = mLocalTime; 388 ZoneId zoneId = ZoneId.systemDefault(); 389 LocalDateTime localNow = toLocalDateTime(now, zoneId); 390 391 count = Math.max(Math.abs(dayDistance(localDateTime, localNow)), 1); 392 result = String.format(getContext().getResources().getQuantityString(past 393 ? com.android.internal. 394 R.plurals.duration_days_relative 395 : com.android.internal. 396 R.plurals.duration_days_relative_future, 397 count), 398 count); 399 400 } else { 401 count = (int)(duration / YEAR_IN_MILLIS); 402 result = String.format(getContext().getResources().getQuantityString(past 403 ? com.android.internal. 404 R.plurals.duration_years_relative 405 : com.android.internal. 406 R.plurals.duration_years_relative_future, 407 count), 408 count); 409 } 410 info.setText(result); 411 } 412 } 413 414 /** 415 * @hide 416 */ setReceiverHandler(Handler handler)417 public static void setReceiverHandler(Handler handler) { 418 ReceiverInfo ri = sReceiverInfo.get(); 419 if (ri == null) { 420 ri = new ReceiverInfo(); 421 sReceiverInfo.set(ri); 422 } 423 ri.setHandler(handler); 424 } 425 426 private static class ReceiverInfo { 427 private final ArrayList<DateTimeView> mAttachedViews = new ArrayList<DateTimeView>(); 428 private final BroadcastReceiver mReceiver = new BroadcastReceiver() { 429 @Override 430 public void onReceive(Context context, Intent intent) { 431 String action = intent.getAction(); 432 if (Intent.ACTION_TIME_TICK.equals(action)) { 433 if (System.currentTimeMillis() < getSoonestUpdateTime()) { 434 // The update() function takes a few milliseconds to run because of 435 // all of the time conversions it needs to do, so we can't do that 436 // every minute. 437 return; 438 } 439 } 440 // ACTION_TIME_CHANGED can also signal a change of 12/24 hr. format. 441 updateAll(); 442 } 443 }; 444 445 private final ContentObserver mObserver = new ContentObserver(new Handler()) { 446 @Override 447 public void onChange(boolean selfChange) { 448 updateAll(); 449 } 450 }; 451 452 private Handler mHandler = new Handler(); 453 addView(DateTimeView v)454 public void addView(DateTimeView v) { 455 synchronized (mAttachedViews) { 456 final boolean register = mAttachedViews.isEmpty(); 457 mAttachedViews.add(v); 458 if (register) { 459 register(getApplicationContextIfAvailable(v.getContext())); 460 } 461 } 462 } 463 removeView(DateTimeView v)464 public void removeView(DateTimeView v) { 465 synchronized (mAttachedViews) { 466 final boolean removed = mAttachedViews.remove(v); 467 // Only unregister once when we remove the last view in the list otherwise we risk 468 // trying to unregister a receiver that is no longer registered. 469 if (removed && mAttachedViews.isEmpty()) { 470 unregister(getApplicationContextIfAvailable(v.getContext())); 471 } 472 } 473 } 474 updateAll()475 void updateAll() { 476 synchronized (mAttachedViews) { 477 final int count = mAttachedViews.size(); 478 for (int i = 0; i < count; i++) { 479 DateTimeView view = mAttachedViews.get(i); 480 view.post(() -> view.clearFormatAndUpdate()); 481 } 482 } 483 } 484 getSoonestUpdateTime()485 long getSoonestUpdateTime() { 486 long result = Long.MAX_VALUE; 487 synchronized (mAttachedViews) { 488 final int count = mAttachedViews.size(); 489 for (int i = 0; i < count; i++) { 490 final long time = mAttachedViews.get(i).mUpdateTimeMillis; 491 if (time < result) { 492 result = time; 493 } 494 } 495 } 496 return result; 497 } 498 getApplicationContextIfAvailable(Context context)499 static final Context getApplicationContextIfAvailable(Context context) { 500 final Context ac = context.getApplicationContext(); 501 return ac != null ? ac : ActivityThread.currentApplication().getApplicationContext(); 502 } 503 register(Context context)504 void register(Context context) { 505 final IntentFilter filter = new IntentFilter(); 506 filter.addAction(Intent.ACTION_TIME_TICK); 507 filter.addAction(Intent.ACTION_TIME_CHANGED); 508 filter.addAction(Intent.ACTION_CONFIGURATION_CHANGED); 509 filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); 510 context.registerReceiver(mReceiver, filter, null, mHandler); 511 } 512 unregister(Context context)513 void unregister(Context context) { 514 context.unregisterReceiver(mReceiver); 515 } 516 setHandler(Handler handler)517 public void setHandler(Handler handler) { 518 mHandler = handler; 519 synchronized (mAttachedViews) { 520 if (!mAttachedViews.isEmpty()) { 521 unregister(mAttachedViews.get(0).getContext()); 522 register(mAttachedViews.get(0).getContext()); 523 } 524 } 525 } 526 } 527 toLocalDateTime(long timeMillis, ZoneId zoneId)528 private static LocalDateTime toLocalDateTime(long timeMillis, ZoneId zoneId) { 529 // java.time types like LocalDateTime / Instant can support the full range of "long millis" 530 // with room to spare so we do not need to worry about overflow / underflow and the rsulting 531 // exceptions while the input to this class is a long. 532 Instant instant = Instant.ofEpochMilli(timeMillis); 533 return LocalDateTime.ofInstant(instant, zoneId); 534 } 535 toEpochMillis(LocalDateTime time, ZoneId zoneId)536 private static long toEpochMillis(LocalDateTime time, ZoneId zoneId) { 537 Instant instant = time.toInstant(zoneId.getRules().getOffset(time)); 538 return instant.toEpochMilli(); 539 } 540 } 541