1 /*
2  * Copyright (C) 2009 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.calendar.widget;
18 
19 import android.app.AlarmManager;
20 import android.app.PendingIntent;
21 import android.appwidget.AppWidgetManager;
22 import android.content.BroadcastReceiver;
23 import android.content.Context;
24 import android.content.CursorLoader;
25 import android.content.Intent;
26 import android.content.Loader;
27 import android.content.res.Resources;
28 import android.database.Cursor;
29 import android.database.MatrixCursor;
30 import android.net.Uri;
31 import android.os.Handler;
32 import android.provider.CalendarContract.Attendees;
33 import android.provider.CalendarContract.Calendars;
34 import android.provider.CalendarContract.Instances;
35 import android.text.format.DateUtils;
36 import android.text.format.Time;
37 import android.util.Log;
38 import android.view.View;
39 import android.widget.RemoteViews;
40 import android.widget.RemoteViewsService;
41 
42 import com.android.calendar.R;
43 import com.android.calendar.Utils;
44 import com.android.calendar.widget.CalendarAppWidgetModel.DayInfo;
45 import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo;
46 import com.android.calendar.widget.CalendarAppWidgetModel.RowInfo;
47 
48 import java.util.concurrent.ExecutorService;
49 import java.util.concurrent.Executors;
50 import java.util.concurrent.atomic.AtomicInteger;
51 
52 
53 public class CalendarAppWidgetService extends RemoteViewsService {
54     private static final String TAG = "CalendarWidget";
55 
56     static final int EVENT_MIN_COUNT = 20;
57     static final int EVENT_MAX_COUNT = 100;
58     // Minimum delay between queries on the database for widget updates in ms
59     static final int WIDGET_UPDATE_THROTTLE = 500;
60 
61     private static final String EVENT_SORT_ORDER = Instances.START_DAY + " ASC, "
62             + Instances.START_MINUTE + " ASC, " + Instances.END_DAY + " ASC, "
63             + Instances.END_MINUTE + " ASC LIMIT " + EVENT_MAX_COUNT;
64 
65     private static final String EVENT_SELECTION = Calendars.VISIBLE + "=1";
66     private static final String EVENT_SELECTION_HIDE_DECLINED = Calendars.VISIBLE + "=1 AND "
67             + Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED;
68 
69     static final String[] EVENT_PROJECTION = new String[] {
70         Instances.ALL_DAY,
71         Instances.BEGIN,
72         Instances.END,
73         Instances.TITLE,
74         Instances.EVENT_LOCATION,
75         Instances.EVENT_ID,
76         Instances.START_DAY,
77         Instances.END_DAY,
78         Instances.DISPLAY_COLOR, // If SDK < 16, set to Instances.CALENDAR_COLOR.
79         Instances.SELF_ATTENDEE_STATUS,
80     };
81 
82     static final int INDEX_ALL_DAY = 0;
83     static final int INDEX_BEGIN = 1;
84     static final int INDEX_END = 2;
85     static final int INDEX_TITLE = 3;
86     static final int INDEX_EVENT_LOCATION = 4;
87     static final int INDEX_EVENT_ID = 5;
88     static final int INDEX_START_DAY = 6;
89     static final int INDEX_END_DAY = 7;
90     static final int INDEX_COLOR = 8;
91     static final int INDEX_SELF_ATTENDEE_STATUS = 9;
92 
93     static {
94         if (!Utils.isJellybeanOrLater()) {
95             EVENT_PROJECTION[INDEX_COLOR] = Instances.CALENDAR_COLOR;
96         }
97     }
98     static final int MAX_DAYS = 7;
99 
100     private static final long SEARCH_DURATION = MAX_DAYS * DateUtils.DAY_IN_MILLIS;
101 
102     /**
103      * Update interval used when no next-update calculated, or bad trigger time in past.
104      * Unit: milliseconds.
105      */
106     private static final long UPDATE_TIME_NO_EVENTS = DateUtils.HOUR_IN_MILLIS * 6;
107 
108     @Override
onGetViewFactory(Intent intent)109     public RemoteViewsFactory onGetViewFactory(Intent intent) {
110         return new CalendarFactory(getApplicationContext(), intent);
111     }
112 
113     public static class CalendarFactory extends BroadcastReceiver implements
114             RemoteViewsService.RemoteViewsFactory, Loader.OnLoadCompleteListener<Cursor> {
115         private static final boolean LOGD = false;
116 
117         // Suppress unnecessary logging about update time. Need to be static as this object is
118         // re-instanciated frequently.
119         // TODO: It seems loadData() is called via onCreate() four times, which should mean
120         // unnecessary CalendarFactory object is created and dropped. It is not efficient.
121         private static long sLastUpdateTime = UPDATE_TIME_NO_EVENTS;
122 
123         private Context mContext;
124         private Resources mResources;
125         private static CalendarAppWidgetModel mModel;
126         private static Object mLock = new Object();
127         private static volatile int mSerialNum = 0;
128         private int mLastSerialNum = -1;
129         private CursorLoader mLoader;
130         private final Handler mHandler = new Handler();
131         private static final AtomicInteger currentVersion = new AtomicInteger(0);
132         private final ExecutorService executor = Executors.newSingleThreadExecutor();
133         private int mAppWidgetId;
134         private int mDeclinedColor;
135         private int mStandardColor;
136         private int mAllDayColor;
137 
138         private final Runnable mTimezoneChanged = new Runnable() {
139             @Override
140             public void run() {
141                 if (mLoader != null) {
142                     mLoader.forceLoad();
143                 }
144             }
145         };
146 
createUpdateLoaderRunnable(final String selection, final PendingResult result, final int version)147         private Runnable createUpdateLoaderRunnable(final String selection,
148                 final PendingResult result, final int version) {
149             return new Runnable() {
150                 @Override
151                 public void run() {
152                     // If there is a newer load request in the queue, skip loading.
153                     if (mLoader != null && version >= currentVersion.get()) {
154                         Uri uri = createLoaderUri();
155                         mLoader.setUri(uri);
156                         mLoader.setSelection(selection);
157                         synchronized (mLock) {
158                             mLastSerialNum = ++mSerialNum;
159                         }
160                         mLoader.forceLoad();
161                     }
162                     result.finish();
163                 }
164             };
165         }
166 
167         protected CalendarFactory(Context context, Intent intent) {
168             mContext = context;
169             mResources = context.getResources();
170             mAppWidgetId = intent.getIntExtra(
171                     AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
172 
173             mDeclinedColor = mResources.getColor(R.color.appwidget_item_declined_color);
174             mStandardColor = mResources.getColor(R.color.appwidget_item_standard_color);
175             mAllDayColor = mResources.getColor(R.color.appwidget_item_allday_color);
176         }
177 
178         public CalendarFactory() {
179             // This is being created as part of onReceive
180 
181         }
182 
183         @Override
184         public void onCreate() {
185             String selection = queryForSelection();
186             initLoader(selection);
187         }
188 
189         @Override
190         public void onDataSetChanged() {
191         }
192 
193         @Override
194         public void onDestroy() {
195             if (mLoader != null) {
196                 mLoader.reset();
197             }
198         }
199 
200         @Override
201         public RemoteViews getLoadingView() {
202             RemoteViews views = new RemoteViews(mContext.getPackageName(),
203                     R.layout.appwidget_loading);
204             return views;
205         }
206 
207         @Override
208         public RemoteViews getViewAt(int position) {
209             // we use getCount here so that it doesn't return null when empty
210             if (position < 0 || position >= getCount()) {
211                 return null;
212             }
213 
214             if (mModel == null) {
215                 RemoteViews views = new RemoteViews(mContext.getPackageName(),
216                         R.layout.appwidget_loading);
217                 final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
218                         0, 0, false);
219                 views.setOnClickFillInIntent(R.id.appwidget_loading, intent);
220                 return views;
221 
222             }
223             if (mModel.mEventInfos.isEmpty() || mModel.mRowInfos.isEmpty()) {
224                 RemoteViews views = new RemoteViews(mContext.getPackageName(),
225                         R.layout.appwidget_no_events);
226                 final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
227                         0, 0, false);
228                 views.setOnClickFillInIntent(R.id.appwidget_no_events, intent);
229                 return views;
230             }
231 
232             RowInfo rowInfo = mModel.mRowInfos.get(position);
233             if (rowInfo.mType == RowInfo.TYPE_DAY) {
234                 RemoteViews views = new RemoteViews(mContext.getPackageName(),
235                         R.layout.appwidget_day);
236                 DayInfo dayInfo = mModel.mDayInfos.get(rowInfo.mIndex);
237                 updateTextView(views, R.id.date, View.VISIBLE, dayInfo.mDayLabel);
238                 return views;
239             } else {
240                 RemoteViews views;
241                 final EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
242                 if (eventInfo.allDay) {
243                     views = new RemoteViews(mContext.getPackageName(),
244                             R.layout.widget_all_day_item);
245                 } else {
246                     views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);
247                 }
248                 int displayColor = Utils.getDisplayColorFromColor(eventInfo.color);
249 
250                 final long now = System.currentTimeMillis();
251                 if (!eventInfo.allDay && eventInfo.start <= now && now <= eventInfo.end) {
252                     views.setInt(R.id.widget_row, "setBackgroundResource",
253                             R.drawable.agenda_item_bg_secondary);
254                 } else {
255                     views.setInt(R.id.widget_row, "setBackgroundResource",
256                             R.drawable.agenda_item_bg_primary);
257                 }
258 
259                 if (!eventInfo.allDay) {
260                     updateTextView(views, R.id.when, eventInfo.visibWhen, eventInfo.when);
261                     updateTextView(views, R.id.where, eventInfo.visibWhere, eventInfo.where);
262                 }
263                 updateTextView(views, R.id.title, eventInfo.visibTitle, eventInfo.title);
264 
265                 views.setViewVisibility(R.id.agenda_item_color, View.VISIBLE);
266 
267                 int selfAttendeeStatus = eventInfo.selfAttendeeStatus;
268                 if (eventInfo.allDay) {
269                     if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
270                         views.setInt(R.id.agenda_item_color, "setImageResource",
271                                 R.drawable.widget_chip_not_responded_bg);
272                         views.setInt(R.id.title, "setTextColor", displayColor);
273                     } else {
274                         views.setInt(R.id.agenda_item_color, "setImageResource",
275                                 R.drawable.widget_chip_responded_bg);
276                         views.setInt(R.id.title, "setTextColor", mAllDayColor);
277                     }
278                     if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
279                         // 40% opacity
280                         views.setInt(R.id.agenda_item_color, "setColorFilter",
281                                 Utils.getDeclinedColorFromColor(displayColor));
282                     } else {
283                         views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
284                     }
285                 } else if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
286                     views.setInt(R.id.title, "setTextColor", mDeclinedColor);
287                     views.setInt(R.id.when, "setTextColor", mDeclinedColor);
288                     views.setInt(R.id.where, "setTextColor", mDeclinedColor);
289                     // views.setInt(R.id.agenda_item_color, "setDrawStyle",
290                     // ColorChipView.DRAW_CROSS_HATCHED);
291                     views.setInt(R.id.agenda_item_color, "setImageResource",
292                             R.drawable.widget_chip_responded_bg);
293                     // 40% opacity
294                     views.setInt(R.id.agenda_item_color, "setColorFilter",
295                             Utils.getDeclinedColorFromColor(displayColor));
296                 } else {
297                     views.setInt(R.id.title, "setTextColor", mStandardColor);
298                     views.setInt(R.id.when, "setTextColor", mStandardColor);
299                     views.setInt(R.id.where, "setTextColor", mStandardColor);
300                     if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
301                         views.setInt(R.id.agenda_item_color, "setImageResource",
302                                 R.drawable.widget_chip_not_responded_bg);
303                     } else {
304                         views.setInt(R.id.agenda_item_color, "setImageResource",
305                                 R.drawable.widget_chip_responded_bg);
306                     }
307                     views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
308                 }
309 
310                 long start = eventInfo.start;
311                 long end = eventInfo.end;
312                 // An element in ListView.
313                 if (eventInfo.allDay) {
314                     String tz = Utils.getTimeZone(mContext, null);
315                     Time recycle = new Time();
316                     start = Utils.convertAlldayLocalToUTC(recycle, start, tz);
317                     end = Utils.convertAlldayLocalToUTC(recycle, end, tz);
318                 }
319                 final Intent fillInIntent = CalendarAppWidgetProvider.getLaunchFillInIntent(
320                         mContext, eventInfo.id, start, end, eventInfo.allDay);
321                 views.setOnClickFillInIntent(R.id.widget_row, fillInIntent);
322                 return views;
323             }
324         }
325 
326         @Override
327         public int getViewTypeCount() {
328             return 5;
329         }
330 
331         @Override
332         public int getCount() {
333             // if there are no events, we still return 1 to represent the "no
334             // events" view
335             if (mModel == null) {
336                 return 1;
337             }
338             return Math.max(1, mModel.mRowInfos.size());
339         }
340 
341         @Override
342         public long getItemId(int position) {
343             if (mModel == null ||  mModel.mRowInfos.isEmpty() || position >= getCount()) {
344                 return 0;
345             }
346             RowInfo rowInfo = mModel.mRowInfos.get(position);
347             if (rowInfo.mType == RowInfo.TYPE_DAY) {
348                 return rowInfo.mIndex;
349             }
350             EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
351             long prime = 31;
352             long result = 1;
353             result = prime * result + (int) (eventInfo.id ^ (eventInfo.id >>> 32));
354             result = prime * result + (int) (eventInfo.start ^ (eventInfo.start >>> 32));
355             return result;
356         }
357 
358         @Override
359         public boolean hasStableIds() {
360             return true;
361         }
362 
363         /**
364          * Query across all calendars for upcoming event instances from now
365          * until some time in the future. Widen the time range that we query by
366          * one day on each end so that we can catch all-day events. All-day
367          * events are stored starting at midnight in UTC but should be included
368          * in the list of events starting at midnight local time. This may fetch
369          * more events than we actually want, so we filter them out later.
370          *
371          * @param selection The selection string for the loader to filter the query with.
372          */
373         public void initLoader(String selection) {
374             if (LOGD)
375                 Log.d(TAG, "Querying for widget events...");
376 
377             // Search for events from now until some time in the future
378             Uri uri = createLoaderUri();
379             mLoader = new CursorLoader(mContext, uri, EVENT_PROJECTION, selection, null,
380                     EVENT_SORT_ORDER);
381             mLoader.setUpdateThrottle(WIDGET_UPDATE_THROTTLE);
382             synchronized (mLock) {
383                 mLastSerialNum = ++mSerialNum;
384             }
385             mLoader.registerListener(mAppWidgetId, this);
386             mLoader.startLoading();
387 
388         }
389 
390         /**
391          * This gets the selection string for the loader.  This ends up doing a query in the
392          * shared preferences.
393          */
394         private String queryForSelection() {
395             return Utils.getHideDeclinedEvents(mContext) ? EVENT_SELECTION_HIDE_DECLINED
396                     : EVENT_SELECTION;
397         }
398 
399         /**
400          * @return The uri for the loader
401          */
402         private Uri createLoaderUri() {
403             long now = System.currentTimeMillis();
404             // Add a day on either side to catch all-day events
405             long begin = now - DateUtils.DAY_IN_MILLIS;
406             long end = now + SEARCH_DURATION + DateUtils.DAY_IN_MILLIS;
407 
408             Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, Long.toString(begin) + "/" + end);
409             return uri;
410         }
411 
412         /* @VisibleForTesting */
413         protected static CalendarAppWidgetModel buildAppWidgetModel(
414                 Context context, Cursor cursor, String timeZone) {
415             CalendarAppWidgetModel model = new CalendarAppWidgetModel(context, timeZone);
416             model.buildFromCursor(cursor, timeZone);
417             return model;
418         }
419 
420         /**
421          * Calculates and returns the next time we should push widget updates.
422          */
423         private long calculateUpdateTime(CalendarAppWidgetModel model, long now, String timeZone) {
424             // Make sure an update happens at midnight or earlier
425             long minUpdateTime = getNextMidnightTimeMillis(timeZone);
426             for (EventInfo event : model.mEventInfos) {
427                 final long start;
428                 final long end;
429                 start = event.start;
430                 end = event.end;
431 
432                 // We want to update widget when we enter/exit time range of an event.
433                 if (now < start) {
434                     minUpdateTime = Math.min(minUpdateTime, start);
435                 } else if (now < end) {
436                     minUpdateTime = Math.min(minUpdateTime, end);
437                 }
438             }
439             return minUpdateTime;
440         }
441 
442         private static long getNextMidnightTimeMillis(String timezone) {
443             Time time = new Time();
444             time.setToNow();
445             time.monthDay++;
446             time.hour = 0;
447             time.minute = 0;
448             time.second = 0;
449             long midnightDeviceTz = time.normalize(true);
450 
451             time.timezone = timezone;
452             time.setToNow();
453             time.monthDay++;
454             time.hour = 0;
455             time.minute = 0;
456             time.second = 0;
457             long midnightHomeTz = time.normalize(true);
458 
459             return Math.min(midnightDeviceTz, midnightHomeTz);
460         }
461 
462         static void updateTextView(RemoteViews views, int id, int visibility, String string) {
463             views.setViewVisibility(id, visibility);
464             if (visibility == View.VISIBLE) {
465                 views.setTextViewText(id, string);
466             }
467         }
468 
469         /*
470          * (non-Javadoc)
471          * @see
472          * android.content.Loader.OnLoadCompleteListener#onLoadComplete(android
473          * .content.Loader, java.lang.Object)
474          */
475         @Override
476         public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
477             if (cursor == null) {
478                 return;
479             }
480             // If a newer update has happened since we started clean up and
481             // return
482             synchronized (mLock) {
483                 if (cursor.isClosed()) {
484                     Log.wtf(TAG, "Got a closed cursor from onLoadComplete");
485                     return;
486                 }
487 
488                 if (mLastSerialNum != mSerialNum) {
489                     return;
490                 }
491 
492                 final long now = System.currentTimeMillis();
493                 String tz = Utils.getTimeZone(mContext, mTimezoneChanged);
494 
495                 // Copy it to a local static cursor.
496                 MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor);
497                 try {
498                     mModel = buildAppWidgetModel(mContext, matrixCursor, tz);
499                 } finally {
500                     if (matrixCursor != null) {
501                         matrixCursor.close();
502                     }
503 
504                     if (cursor != null) {
505                         cursor.close();
506                     }
507                 }
508 
509                 // Schedule an alarm to wake ourselves up for the next update.
510                 // We also cancel
511                 // all existing wake-ups because PendingIntents don't match
512                 // against extras.
513                 long triggerTime = calculateUpdateTime(mModel, now, tz);
514 
515                 // If no next-update calculated, or bad trigger time in past,
516                 // schedule
517                 // update about six hours from now.
518                 if (triggerTime < now) {
519                     Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now));
520                     triggerTime = now + UPDATE_TIME_NO_EVENTS;
521                 }
522 
523                 final AlarmManager alertManager = (AlarmManager) mContext
524                         .getSystemService(Context.ALARM_SERVICE);
525                 final PendingIntent pendingUpdate = CalendarAppWidgetProvider
526                         .getUpdateIntent(mContext);
527 
528                 alertManager.cancel(pendingUpdate);
529                 alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate);
530                 Time time = new Time(Utils.getTimeZone(mContext, null));
531                 time.setToNow();
532 
533                 if (time.normalize(true) != sLastUpdateTime) {
534                     Time time2 = new Time(Utils.getTimeZone(mContext, null));
535                     time2.set(sLastUpdateTime);
536                     time2.normalize(true);
537                     if (time.year != time2.year || time.yearDay != time2.yearDay) {
538                         final Intent updateIntent = new Intent(
539                                 Utils.getWidgetUpdateAction(mContext));
540                         mContext.sendBroadcast(updateIntent);
541                     }
542 
543                     sLastUpdateTime = time.toMillis(true);
544                 }
545 
546                 AppWidgetManager widgetManager = AppWidgetManager.getInstance(mContext);
547                 if (mAppWidgetId == -1) {
548                     int[] ids = widgetManager.getAppWidgetIds(CalendarAppWidgetProvider
549                             .getComponentName(mContext));
550 
551                     widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list);
552                 } else {
553                     widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list);
554                 }
555             }
556         }
557 
558         @Override
559         public void onReceive(Context context, Intent intent) {
560             if (LOGD)
561                 Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString());
562             mContext = context;
563 
564             // We cannot do any queries from the UI thread, so push the 'selection' query
565             // to a background thread.  However the implementation of the latter query
566             // (cursor loading) uses CursorLoader which must be initiated from the UI thread,
567             // so there is some convoluted handshaking here.
568             //
569             // Note that as currently implemented, this must run in a single threaded executor
570             // or else the loads may be run out of order.
571             //
572             // TODO: Remove use of mHandler and CursorLoader, and do all the work synchronously
573             // in the background thread.  All the handshaking going on here between the UI and
574             // background thread with using goAsync, mHandler, and CursorLoader is confusing.
575             final PendingResult result = goAsync();
576             executor.submit(new Runnable() {
577                 @Override
578                 public void run() {
579                     // We always complete queryForSelection() even if the load task ends up being
580                     // canceled because of a more recent one.  Optimizing this to allow
581                     // canceling would require keeping track of all the PendingResults
582                     // (from goAsync) to abort them.  Defer this until it becomes a problem.
583                     final String selection = queryForSelection();
584 
585                     if (mLoader == null) {
586                         mAppWidgetId = -1;
587                         mHandler.post(new Runnable() {
588                             @Override
589                             public void run() {
590                                 initLoader(selection);
591                                 result.finish();
592                             }
593                         });
594                     } else {
595                         mHandler.post(createUpdateLoaderRunnable(selection, result,
596                                 currentVersion.incrementAndGet()));
597                     }
598                 }
599             });
600         }
601     }
602 
603     /**
604      * Format given time for debugging output.
605      *
606      * @param unixTime Target time to report.
607      * @param now Current system time from {@link System#currentTimeMillis()}
608      *            for calculating time difference.
609      */
610     static String formatDebugTime(long unixTime, long now) {
611         Time time = new Time();
612         time.set(unixTime);
613 
614         long delta = unixTime - now;
615         if (delta > DateUtils.MINUTE_IN_MILLIS) {
616             delta /= DateUtils.MINUTE_IN_MILLIS;
617             return String.format("[%d] %s (%+d mins)", unixTime,
618                     time.format("%H:%M:%S"), delta);
619         } else {
620             delta /= DateUtils.SECOND_IN_MILLIS;
621             return String.format("[%d] %s (%+d secs)", unixTime,
622                     time.format("%H:%M:%S"), delta);
623         }
624     }
625 }
626