diff options
Diffstat (limited to 'src/com/android/calendar/widget/CalendarAppWidgetService.kt')
-rw-r--r-- | src/com/android/calendar/widget/CalendarAppWidgetService.kt | 665 |
1 files changed, 665 insertions, 0 deletions
diff --git a/src/com/android/calendar/widget/CalendarAppWidgetService.kt b/src/com/android/calendar/widget/CalendarAppWidgetService.kt new file mode 100644 index 00000000..114fdf12 --- /dev/null +++ b/src/com/android/calendar/widget/CalendarAppWidgetService.kt @@ -0,0 +1,665 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.calendar.widget + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.CursorLoader +import android.content.Intent +import android.content.Loader +import android.content.res.Resources +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Handler +import android.provider.CalendarContract.Attendees +import android.provider.CalendarContract.Calendars +import android.provider.CalendarContract.Instances +import android.text.format.DateUtils +import android.text.format.Time +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import com.android.calendar.R +import com.android.calendar.Utils +import com.android.calendar.widget.CalendarAppWidgetModel.DayInfo +import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo +import com.android.calendar.widget.CalendarAppWidgetModel.RowInfo +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +class CalendarAppWidgetService : RemoteViewsService() { + companion object { + private const val TAG = "CalendarWidget" + const val EVENT_MIN_COUNT = 20 + const val EVENT_MAX_COUNT = 100 + + // Minimum delay between queries on the database for widget updates in ms + const val WIDGET_UPDATE_THROTTLE = 500 + private val EVENT_SORT_ORDER: String = (Instances.START_DAY.toString() + " ASC, " + + Instances.START_MINUTE + " ASC, " + Instances.END_DAY + " ASC, " + + Instances.END_MINUTE + " ASC LIMIT " + EVENT_MAX_COUNT) + private val EVENT_SELECTION: String = Calendars.VISIBLE.toString() + "=1" + private val EVENT_SELECTION_HIDE_DECLINED: String = + (Calendars.VISIBLE.toString() + "=1 AND " + + Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED) + @JvmField + val EVENT_PROJECTION = arrayOf<String>( + Instances.ALL_DAY, + Instances.BEGIN, + Instances.END, + Instances.TITLE, + Instances.EVENT_LOCATION, + Instances.EVENT_ID, + Instances.START_DAY, + Instances.END_DAY, + Instances.DISPLAY_COLOR, // If SDK < 16, set to Instances.CALENDAR_COLOR. + Instances.SELF_ATTENDEE_STATUS + ) + const val INDEX_ALL_DAY = 0 + const val INDEX_BEGIN = 1 + const val INDEX_END = 2 + const val INDEX_TITLE = 3 + const val INDEX_EVENT_LOCATION = 4 + const val INDEX_EVENT_ID = 5 + const val INDEX_START_DAY = 6 + const val INDEX_END_DAY = 7 + const val INDEX_COLOR = 8 + const val INDEX_SELF_ATTENDEE_STATUS = 9 + const val MAX_DAYS = 7 + private val SEARCH_DURATION: Long = MAX_DAYS * DateUtils.DAY_IN_MILLIS + + /** + * Update interval used when no next-update calculated, or bad trigger time in past. + * Unit: milliseconds. + */ + private val UPDATE_TIME_NO_EVENTS: Long = DateUtils.HOUR_IN_MILLIS * 6 + + /** + * Format given time for debugging output. + * + * @param unixTime Target time to report. + * @param now Current system time from [System.currentTimeMillis] + * for calculating time difference. + */ + fun formatDebugTime(unixTime: Long, now: Long): String { + val time = Time() + time.set(unixTime) + var delta = unixTime - now + return if (delta > DateUtils.MINUTE_IN_MILLIS) { + delta /= DateUtils.MINUTE_IN_MILLIS + String.format( + "[%d] %s (%+d mins)", unixTime, + time.format("%H:%M:%S"), delta + ) + } else { + delta /= DateUtils.SECOND_IN_MILLIS + String.format( + "[%d] %s (%+d secs)", unixTime, + time.format("%H:%M:%S"), delta + ) + } + } + + init { + if (!Utils.isJellybeanOrLater()) { + EVENT_PROJECTION[INDEX_COLOR] = Instances.CALENDAR_COLOR + } + } + } + + @Override + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { + return CalendarFactory(getApplicationContext(), intent) + } + + class CalendarFactory : BroadcastReceiver, RemoteViewsService.RemoteViewsFactory, + Loader.OnLoadCompleteListener<Cursor?> { + private var mContext: Context? = null + private var mResources: Resources? = null + private var mLastSerialNum = -1 + private var mLoader: CursorLoader? = null + private val mHandler: Handler = Handler() + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private var mAppWidgetId = 0 + private var mDeclinedColor = 0 + private var mStandardColor = 0 + private var mAllDayColor = 0 + private val mTimezoneChanged: Runnable = object : Runnable { + @Override + override fun run() { + if (mLoader != null) { + mLoader?.forceLoad() + } + } + } + + private fun createUpdateLoaderRunnable( + selection: String, + result: PendingResult, + version: Int + ): Runnable { + return object : Runnable { + @Override + override fun run() { + // If there is a newer load request in the queue, skip loading. + if (mLoader != null && version >= currentVersion.get()) { + val uri: Uri = createLoaderUri() + mLoader?.setUri(uri) + mLoader?.setSelection(selection) + synchronized(mLock) { mLastSerialNum = ++mSerialNum } + mLoader?.forceLoad() + } + result.finish() + } + } + } + + constructor(context: Context, intent: Intent) { + mContext = context + mResources = context.getResources() + mAppWidgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID + ) + mDeclinedColor = mResources?.getColor(R.color.appwidget_item_declined_color) as Int + mStandardColor = mResources?.getColor(R.color.appwidget_item_standard_color) as Int + mAllDayColor = mResources?.getColor(R.color.appwidget_item_allday_color) as Int + } + + constructor() { + // This is being created as part of onReceive + } + + @Override + override fun onCreate() { + val selection = queryForSelection() + initLoader(selection) + } + + @Override + override fun onDataSetChanged() { + } + + @Override + override fun onDestroy() { + if (mLoader != null) { + mLoader?.reset() + } + } + + @Override + override fun getLoadingView(): RemoteViews { + val views = RemoteViews(mContext?.getPackageName(), R.layout.appwidget_loading) + return views + } + + @Override + override fun getViewAt(position: Int): RemoteViews? { + // we use getCount here so that it doesn't return null when empty + if (position < 0 || position >= getCount()) { + return null + } + if (mModel == null) { + val views = RemoteViews( + mContext?.getPackageName(), + R.layout.appwidget_loading + ) + val intent: Intent = CalendarAppWidgetProvider.getLaunchFillInIntent( + mContext, + 0, + 0, + 0, + false + ) + views.setOnClickFillInIntent(R.id.appwidget_loading, intent) + return views + } + if (mModel!!.mEventInfos!!.isEmpty() || mModel!!.mRowInfos!!.isEmpty()) { + val views = RemoteViews( + mContext?.getPackageName(), + R.layout.appwidget_no_events + ) + val intent: Intent = CalendarAppWidgetProvider.getLaunchFillInIntent( + mContext, + 0, + 0, + 0, + false + ) + views.setOnClickFillInIntent(R.id.appwidget_no_events, intent) + return views + } + val rowInfo: RowInfo? = mModel?.mRowInfos?.get(position) + return if (rowInfo!!.mType == RowInfo!!.TYPE_DAY) { + val views = RemoteViews( + mContext?.getPackageName(), + R.layout.appwidget_day + ) + val dayInfo: DayInfo? = mModel?.mDayInfos?.get(rowInfo!!.mIndex) + updateTextView(views, R.id.date, View.VISIBLE, dayInfo!!.mDayLabel) + views + } else { + val views: RemoteViews? + val eventInfo: EventInfo? = mModel?.mEventInfos?.get(rowInfo.mIndex) + if (eventInfo!!.allDay) { + views = RemoteViews( + mContext?.getPackageName(), + R.layout.widget_all_day_item + ) + } else { + views = RemoteViews(mContext?.getPackageName(), R.layout.widget_item) + } + val displayColor: Int = Utils.getDisplayColorFromColor(eventInfo!!.color) + val now: Long = System.currentTimeMillis() + if (!eventInfo!!.allDay && eventInfo!!.start <= now && now <= eventInfo!!.end) { + views?.setInt( + R.id.widget_row, "setBackgroundResource", + R.drawable.agenda_item_bg_secondary + ) + } else { + views?.setInt( + R.id.widget_row, "setBackgroundResource", + R.drawable.agenda_item_bg_primary + ) + } + if (!eventInfo?.allDay) { + updateTextView(views, R.id.`when`, eventInfo?.visibWhen + as Int, eventInfo?.`when`) + updateTextView(views, R.id.where, eventInfo?.visibWhere + as Int, eventInfo?.where) + } + updateTextView(views, R.id.title, eventInfo?.visibTitle as Int, eventInfo?.title) + views.setViewVisibility(R.id.agenda_item_color, View.VISIBLE) + val selfAttendeeStatus: Int = eventInfo?.selfAttendeeStatus as Int + if (eventInfo!!.allDay) { + if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) { + views?.setInt( + R.id.agenda_item_color, "setImageResource", + R.drawable.widget_chip_not_responded_bg + ) + views?.setInt(R.id.title, "setTextColor", displayColor) + } else { + views?.setInt( + R.id.agenda_item_color, "setImageResource", + R.drawable.widget_chip_responded_bg + ) + views?.setInt(R.id.title, "setTextColor", mAllDayColor) + } + if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) { + // 40% opacity + views?.setInt( + R.id.agenda_item_color, "setColorFilter", + Utils.getDeclinedColorFromColor(displayColor) + ) + } else { + views?.setInt(R.id.agenda_item_color, "setColorFilter", displayColor) + } + } else if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) { + views?.setInt(R.id.title, "setTextColor", mDeclinedColor) + views?.setInt(R.id.`when`, "setTextColor", mDeclinedColor) + views?.setInt(R.id.where, "setTextColor", mDeclinedColor) + views?.setInt( + R.id.agenda_item_color, "setImageResource", + R.drawable.widget_chip_responded_bg + ) + // 40% opacity + views?.setInt( + R.id.agenda_item_color, "setColorFilter", + Utils.getDeclinedColorFromColor(displayColor) + ) + } else { + views?.setInt(R.id.title, "setTextColor", mStandardColor) + views?.setInt(R.id.`when`, "setTextColor", mStandardColor) + views?.setInt(R.id.where, "setTextColor", mStandardColor) + if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) { + views?.setInt( + R.id.agenda_item_color, "setImageResource", + R.drawable.widget_chip_not_responded_bg + ) + } else { + views?.setInt( + R.id.agenda_item_color, "setImageResource", + R.drawable.widget_chip_responded_bg + ) + } + views?.setInt(R.id.agenda_item_color, "setColorFilter", displayColor) + } + var start: Long = eventInfo?.start as Long + var end: Long = eventInfo?.end as Long + // An element in ListView. + if (eventInfo!!.allDay) { + val tz: String? = Utils.getTimeZone(mContext, null) + val recycle = Time() + start = Utils.convertAlldayLocalToUTC(recycle, start, tz as String) + end = Utils.convertAlldayLocalToUTC(recycle, end, tz as String) + } + val fillInIntent: Intent = CalendarAppWidgetProvider.getLaunchFillInIntent( + mContext, eventInfo?.id, start, end, eventInfo?.allDay + ) + views.setOnClickFillInIntent(R.id.widget_row, fillInIntent) + views + } + } + + @Override + override fun getViewTypeCount(): Int { + return 5 + } + + @Override + override fun getCount(): Int { + // if there are no events, we still return 1 to represent the "no + // events" view + if (mModel == null) { + return 1 + } + return Math.max(1, mModel?.mRowInfos?.size as Int) + } + + @Override + override fun getItemId(position: Int): Long { + if (mModel == null || mModel?.mRowInfos?.isEmpty() as Boolean || + position >= getCount()) { + return 0 + } + val rowInfo: RowInfo = mModel?.mRowInfos?.get(position) as RowInfo + if (rowInfo.mType == RowInfo.TYPE_DAY) { + return rowInfo.mIndex.toLong() + } + val eventInfo: EventInfo = mModel?.mEventInfos?.get(rowInfo.mIndex) as EventInfo + val prime: Long = 31 + var result: Long = 1 + result = prime * result + (eventInfo.id xor (eventInfo.id ushr 32)) as Int + result = prime * result + (eventInfo.start xor (eventInfo.start ushr 32)) as Int + return result + } + + @Override + override fun hasStableIds(): Boolean { + return true + } + + /** + * Query across all calendars for upcoming event instances from now + * until some time in the future. Widen the time range that we query by + * one day on each end so that we can catch all-day events. All-day + * events are stored starting at midnight in UTC but should be included + * in the list of events starting at midnight local time. This may fetch + * more events than we actually want, so we filter them out later. + * + * @param selection The selection string for the loader to filter the query with. + */ + fun initLoader(selection: String?) { + if (LOGD) Log.d(TAG, "Querying for widget events...") + + // Search for events from now until some time in the future + val uri: Uri = createLoaderUri() + mLoader = CursorLoader( + mContext, uri, EVENT_PROJECTION, selection, null, + EVENT_SORT_ORDER + ) + mLoader?.setUpdateThrottle(WIDGET_UPDATE_THROTTLE.toLong()) + synchronized(mLock) { mLastSerialNum = ++mSerialNum } + mLoader?.registerListener(mAppWidgetId, this) + mLoader?.startLoading() + } + + /** + * This gets the selection string for the loader. This ends up doing a query in the + * shared preferences. + */ + private fun queryForSelection(): String { + return if (Utils.getHideDeclinedEvents(mContext)) EVENT_SELECTION_HIDE_DECLINED + else EVENT_SELECTION + } + + /** + * @return The uri for the loader + */ + private fun createLoaderUri(): Uri { + val now: Long = System.currentTimeMillis() + // Add a day on either side to catch all-day events + val begin: Long = now - DateUtils.DAY_IN_MILLIS + val end: Long = + now + SEARCH_DURATION + DateUtils.DAY_IN_MILLIS + return Uri.withAppendedPath( + Instances.CONTENT_URI, + begin.toString() + "/" + end + ) + } + + /** + * Calculates and returns the next time we should push widget updates. + */ + private fun calculateUpdateTime( + model: CalendarAppWidgetModel, + now: Long, + timeZone: String + ): Long { + // Make sure an update happens at midnight or earlier + var minUpdateTime = getNextMidnightTimeMillis(timeZone) + for (event in model.mEventInfos) { + val start: Long + val end: Long + start = event.start + end = event.end + + // We want to update widget when we enter/exit time range of an event. + if (now < start) { + minUpdateTime = Math.min(minUpdateTime, start) + } else if (now < end) { + minUpdateTime = Math.min(minUpdateTime, end) + } + } + return minUpdateTime + } + + /* + * (non-Javadoc) + * @see + * android.content.Loader.OnLoadCompleteListener#onLoadComplete(android + * .content.Loader, java.lang.Object) + */ + @Override + override fun onLoadComplete(loader: Loader<Cursor?>?, cursor: Cursor?) { + if (cursor == null) { + return + } + // If a newer update has happened since we started clean up and + // return + synchronized(mLock) { + if (cursor.isClosed()) { + Log.wtf(TAG, "Got a closed cursor from onLoadComplete") + return + } + if (mLastSerialNum != mSerialNum) { + return + } + val now: Long = System.currentTimeMillis() + val tz: String? = Utils.getTimeZone(mContext, mTimezoneChanged) + + // Copy it to a local static cursor. + val matrixCursor: MatrixCursor? = Utils.matrixCursorFromCursor(cursor) + try { + mModel = buildAppWidgetModel(mContext, matrixCursor, tz) + } finally { + if (matrixCursor != null) { + matrixCursor?.close() + } + if (cursor != null) { + cursor?.close() + } + } + + // Schedule an alarm to wake ourselves up for the next update. + // We also cancel + // all existing wake-ups because PendingIntents don't match + // against extras. + var triggerTime = calculateUpdateTime(mModel as CalendarAppWidgetModel, + now, tz as String) + + // If no next-update calculated, or bad trigger time in past, + // schedule + // update about six hours from now. + if (triggerTime < now) { + Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now)) + triggerTime = now + UPDATE_TIME_NO_EVENTS + } + val alertManager: AlarmManager = mContext + ?.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pendingUpdate: PendingIntent = CalendarAppWidgetProvider + .getUpdateIntent(mContext) + alertManager.cancel(pendingUpdate) + alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate) + val time = Time(Utils.getTimeZone(mContext, null)) + time.setToNow() + if (time.normalize(true) !== sLastUpdateTime) { + val time2 = Time(Utils.getTimeZone(mContext, null)) + time2.set(sLastUpdateTime) + time2.normalize(true) + if (time.year !== time2.year || time.yearDay !== time2.yearDay) { + val updateIntent = Intent( + Utils.getWidgetUpdateAction(mContext as Context) + ) + mContext?.sendBroadcast(updateIntent) + } + sLastUpdateTime = time.toMillis(true) + } + val widgetManager: AppWidgetManager = AppWidgetManager.getInstance(mContext) + if (widgetManager == null) { + return + } + if (mAppWidgetId == -1) { + val ids: IntArray = widgetManager.getAppWidgetIds( + CalendarAppWidgetProvider + .getComponentName(mContext) + ) + widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list) + } else { + widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list) + } + } + } + + @Override + override fun onReceive(context: Context?, intent: Intent) { + if (LOGD) Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString()) + mContext = context + + // We cannot do any queries from the UI thread, so push the 'selection' query + // to a background thread. However the implementation of the latter query + // (cursor loading) uses CursorLoader which must be initiated from the UI thread, + // so there is some convoluted handshaking here. + // + // Note that as currently implemented, this must run in a single threaded executor + // or else the loads may be run out of order. + // + // TODO: Remove use of mHandler and CursorLoader, and do all the work synchronously + // in the background thread. All the handshaking going on here between the UI and + // background thread with using goAsync, mHandler, and CursorLoader is confusing. + val result: PendingResult = goAsync() + executor.submit(object : Runnable { + @Override + override fun run() { + // We always complete queryForSelection() even if the load task ends up being + // canceled because of a more recent one. Optimizing this to allow + // canceling would require keeping track of all the PendingResults + // (from goAsync) to abort them. Defer this until it becomes a problem. + val selection = queryForSelection() + if (mLoader == null) { + mAppWidgetId = -1 + mHandler.post(object : Runnable { + @Override + override fun run() { + initLoader(selection) + result.finish() + } + }) + } else { + mHandler.post( + createUpdateLoaderRunnable( + selection, result, + currentVersion.incrementAndGet() + ) + ) + } + } + }) + } + + internal companion object { + private const val LOGD = false + + // Suppress unnecessary logging about update time. Need to be static as this object is + // re-instantiated frequently. + // TODO: It seems loadData() is called via onCreate() four times, which should mean + // unnecessary CalendarFactory object is created and dropped. It is not efficient. + private var sLastUpdateTime = UPDATE_TIME_NO_EVENTS + private var mModel: CalendarAppWidgetModel? = null + private val mLock: Object = Object() + + @Volatile + private var mSerialNum = 0 + private val currentVersion: AtomicInteger = AtomicInteger(0) + + /* @VisibleForTesting */ + @JvmStatic protected fun buildAppWidgetModel( + context: Context?, + cursor: Cursor?, + timeZone: String? + ): CalendarAppWidgetModel { + val model = CalendarAppWidgetModel(context as Context, timeZone) + model.buildFromCursor(cursor as Cursor, timeZone) + return model + } + + @JvmStatic private fun getNextMidnightTimeMillis(timezone: String): Long { + val time = Time() + time.setToNow() + time.monthDay++ + time.hour = 0 + time.minute = 0 + time.second = 0 + val midnightDeviceTz: Long = time.normalize(true) + time.timezone = timezone + time.setToNow() + time.monthDay++ + time.hour = 0 + time.minute = 0 + time.second = 0 + val midnightHomeTz: Long = time.normalize(true) + return Math.min(midnightDeviceTz, midnightHomeTz) + } + + @JvmStatic fun updateTextView( + views: RemoteViews, + id: Int, + visibility: Int, + string: String? + ) { + views.setViewVisibility(id, visibility) + if (visibility == View.VISIBLE) { + views.setTextViewText(id, string) + } + } + } + } +}
\ No newline at end of file |