• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License"); you may not
5  * use this file except in compliance with the License. You may obtain a copy of
6  * 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, WITHOUT
12  * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13  * License for the specific language governing permissions and limitations under
14  * the License.
15  */
16 package com.android.alarmclock
17 
18 import android.annotation.SuppressLint
19 import android.app.AlarmManager
20 import android.app.AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED
21 import android.app.PendingIntent
22 import android.app.PendingIntent.FLAG_NO_CREATE
23 import android.app.PendingIntent.FLAG_UPDATE_CURRENT
24 import android.appwidget.AppWidgetManager
25 import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT
26 import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH
27 import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT
28 import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH
29 import android.appwidget.AppWidgetProvider
30 import android.content.ComponentName
31 import android.content.Context
32 import android.content.Intent
33 import android.content.Intent.ACTION_DATE_CHANGED
34 import android.content.Intent.ACTION_LOCALE_CHANGED
35 import android.content.Intent.ACTION_SCREEN_ON
36 import android.content.Intent.ACTION_TIMEZONE_CHANGED
37 import android.content.Intent.ACTION_TIME_CHANGED
38 import android.content.res.Resources
39 import android.graphics.Bitmap
40 import android.net.Uri
41 import android.os.Bundle
42 import android.text.TextUtils
43 import android.text.format.DateFormat
44 import android.util.ArraySet
45 import android.util.TypedValue.COMPLEX_UNIT_PX
46 import android.view.LayoutInflater
47 import android.view.View
48 import android.view.View.GONE
49 import android.view.View.MeasureSpec.UNSPECIFIED
50 import android.view.View.VISIBLE
51 import android.widget.RemoteViews
52 import android.widget.TextClock
53 import android.widget.TextView
54 
55 import com.android.deskclock.DeskClock
56 import com.android.deskclock.LogUtils
57 import com.android.deskclock.R
58 import com.android.deskclock.Utils
59 import com.android.deskclock.alarms.AlarmStateManager
60 import com.android.deskclock.data.DataModel
61 import com.android.deskclock.uidata.UiDataModel
62 import com.android.deskclock.worldclock.CitySelectionActivity
63 
64 import java.util.Calendar
65 import java.util.Date
66 import java.util.Locale
67 import java.util.TimeZone
68 
69 /**
70  * This provider produces a widget resembling one of the formats below.
71  *
72  * If an alarm is scheduled to ring in the future:
73  * <pre>
74  *      12:59 AM
75  *      WED, FEB 3 ⏰ THU 9:30 AM
76  * </pre>
77  *
78  * If no alarm is scheduled to ring in the future:
79  * <pre>
80  *      12:59 AM
81  *      WED, FEB 3
82  * </pre>
83  *
84  * This widget is scaling the font sizes to fit within the widget bounds chosen by the user without
85  * any clipping. To do so it measures layouts offscreen using a range of font sizes in order to
86  * choose optimal values.
87  */
88 class DigitalAppWidgetProvider : AppWidgetProvider() {
89 
90     override fun onEnabled(context: Context) {
91         super.onEnabled(context)
92 
93         // Schedule the day-change callback if necessary.
94         updateDayChangeCallback(context)
95     }
96 
97     override fun onDisabled(context: Context) {
98         super.onDisabled(context)
99 
100         // Remove any scheduled day-change callback.
101         removeDayChangeCallback(context)
102     }
103 
104     override fun onReceive(context: Context, intent: Intent) {
105         LOGGER.i("onReceive: $intent")
106         super.onReceive(context, intent)
107 
108         val wm: AppWidgetManager = AppWidgetManager.getInstance(context) ?: return
109 
110         val provider = ComponentName(context, javaClass)
111         val widgetIds: IntArray = wm.getAppWidgetIds(provider)
112 
113         val action: String? = intent.action
114         when (action) {
115             ACTION_NEXT_ALARM_CLOCK_CHANGED,
116             ACTION_DATE_CHANGED,
117             ACTION_LOCALE_CHANGED,
118             ACTION_SCREEN_ON,
119             ACTION_TIME_CHANGED,
120             ACTION_TIMEZONE_CHANGED,
121             AlarmStateManager.ACTION_ALARM_CHANGED,
122             ACTION_ON_DAY_CHANGE,
123             DataModel.ACTION_WORLD_CITIES_CHANGED -> widgetIds.forEach { widgetId ->
124                 relayoutWidget(context, wm, widgetId, wm.getAppWidgetOptions(widgetId))
125             }
126         }
127 
128         val dm = DataModel.dataModel
129         dm.updateWidgetCount(javaClass, widgetIds.size, R.string.category_digital_widget)
130 
131         if (widgetIds.size > 0) {
132             updateDayChangeCallback(context)
133         }
134     }
135 
136     /**
137      * Called when widgets must provide remote views.
138      */
139     override fun onUpdate(context: Context, wm: AppWidgetManager, widgetIds: IntArray) {
140         super.onUpdate(context, wm, widgetIds)
141 
142         widgetIds.forEach { widgetId ->
143             relayoutWidget(context, wm, widgetId, wm.getAppWidgetOptions(widgetId))
144         }
145     }
146 
147     /**
148      * Called when the app widget changes sizes.
149      */
150     override fun onAppWidgetOptionsChanged(
151         context: Context,
152         wm: AppWidgetManager?,
153         widgetId: Int,
154         options: Bundle
155     ) {
156         super.onAppWidgetOptionsChanged(context, wm, widgetId, options)
157 
158         // Scale the fonts of the clock to fit inside the new size
159         relayoutWidget(context, AppWidgetManager.getInstance(context), widgetId, options)
160     }
161 
162     /**
163      * Remove the existing day-change callback if it is not needed (no selected cities exist).
164      * Add the day-change callback if it is needed (selected cities exist).
165      */
166     private fun updateDayChangeCallback(context: Context) {
167         val dm = DataModel.dataModel
168         val selectedCities = dm.selectedCities
169         val showHomeClock = dm.showHomeClock
170         if (selectedCities.isEmpty() && !showHomeClock) {
171             // Remove the existing day-change callback.
172             removeDayChangeCallback(context)
173             return
174         }
175 
176         // Look up the time at which the next day change occurs across all timezones.
177         val zones: MutableSet<TimeZone> = ArraySet(selectedCities.size + 2)
178         zones.add(TimeZone.getDefault())
179         if (showHomeClock) {
180             zones.add(dm.homeCity.timeZone)
181         }
182         selectedCities.forEach { city ->
183             zones.add(city.timeZone)
184         }
185         val nextDay = Utils.getNextDay(Date(), zones)
186 
187         // Schedule the next day-change callback; at least one city is displayed.
188         val pi: PendingIntent =
189                 PendingIntent.getBroadcast(context, 0, DAY_CHANGE_INTENT, FLAG_UPDATE_CURRENT)
190         getAlarmManager(context).setExact(AlarmManager.RTC, nextDay.time, pi)
191     }
192 
193     /**
194      * Remove the existing day-change callback.
195      */
196     private fun removeDayChangeCallback(context: Context) {
197         val pi: PendingIntent? =
198                 PendingIntent.getBroadcast(context, 0, DAY_CHANGE_INTENT, FLAG_NO_CREATE)
199         if (pi != null) {
200             getAlarmManager(context).cancel(pi)
201             pi.cancel()
202         }
203     }
204 
205     /**
206      * This class stores the target size of the widget as well as the measured size using a given
207      * clock font size. All other fonts and icons are scaled proportional to the clock font.
208      */
209     private class Sizes(
210         val mTargetWidthPx: Int,
211         val mTargetHeightPx: Int,
212         val largestClockFontSizePx: Int
213     ) {
214         val smallestClockFontSizePx = 1
215         var mIconBitmap: Bitmap? = null
216 
217         var mMeasuredWidthPx = 0
218         var mMeasuredHeightPx = 0
219         var mMeasuredTextClockWidthPx = 0
220         var mMeasuredTextClockHeightPx = 0
221 
222         /** The size of the font to use on the date / next alarm time fields.  */
223         var mFontSizePx = 0
224 
225         /** The size of the font to use on the clock field.  */
226         var mClockFontSizePx = 0
227 
228         var mIconFontSizePx = 0
229         var mIconPaddingPx = 0
230 
231         var clockFontSizePx: Int
232             get() = mClockFontSizePx
233             set(clockFontSizePx) {
234                 mClockFontSizePx = clockFontSizePx
235                 mFontSizePx = Math.max(1, Math.round(clockFontSizePx / 7.5f))
236                 mIconFontSizePx = (mFontSizePx * 1.4f).toInt()
237                 mIconPaddingPx = mFontSizePx / 3
238             }
239 
240         /**
241          * @return the amount of widget height available to the world cities list
242          */
243         val listHeight: Int
244             get() = mTargetHeightPx - mMeasuredHeightPx
245 
246         fun hasViolations(): Boolean {
247             return mMeasuredWidthPx > mTargetWidthPx || mMeasuredHeightPx > mTargetHeightPx
248         }
249 
250         fun newSize(): Sizes {
251             return Sizes(mTargetWidthPx, mTargetHeightPx, largestClockFontSizePx)
252         }
253 
254         override fun toString(): String {
255             val builder = StringBuilder(1000)
256             builder.append("\n")
257             append(builder, "Target dimensions: %dpx x %dpx\n", mTargetWidthPx, mTargetHeightPx)
258             append(builder, "Last valid widget container measurement: %dpx x %dpx\n",
259                     mMeasuredWidthPx, mMeasuredHeightPx)
260             append(builder, "Last text clock measurement: %dpx x %dpx\n",
261                     mMeasuredTextClockWidthPx, mMeasuredTextClockHeightPx)
262             if (mMeasuredWidthPx > mTargetWidthPx) {
263                 append(builder, "Measured width %dpx exceeded widget width %dpx\n",
264                         mMeasuredWidthPx, mTargetWidthPx)
265             }
266             if (mMeasuredHeightPx > mTargetHeightPx) {
267                 append(builder, "Measured height %dpx exceeded widget height %dpx\n",
268                         mMeasuredHeightPx, mTargetHeightPx)
269             }
270             append(builder, "Clock font: %dpx\n", mClockFontSizePx)
271             return builder.toString()
272         }
273 
274         companion object {
275             private fun append(builder: StringBuilder, format: String, vararg args: Any) {
276                 builder.append(String.format(Locale.ENGLISH, format, *args))
277             }
278         }
279     }
280 
281     companion object {
282         private val LOGGER = LogUtils.Logger("DigitalWidgetProvider")
283 
284         /**
285          * Intent action used for refreshing a world city display when any of them changes days or when
286          * the default TimeZone changes days. This affects the widget display because the day-of-week is
287          * only visible when the world city day-of-week differs from the default TimeZone's day-of-week.
288          */
289         private const val ACTION_ON_DAY_CHANGE = "com.android.deskclock.ON_DAY_CHANGE"
290 
291         /** Intent used to deliver the [.ACTION_ON_DAY_CHANGE] callback.  */
292         private val DAY_CHANGE_INTENT: Intent = Intent(ACTION_ON_DAY_CHANGE)
293 
294         /**
295          * Compute optimal font and icon sizes offscreen for both portrait and landscape orientations
296          * using the last known widget size and apply them to the widget.
297          */
298         private fun relayoutWidget(
299             context: Context,
300             wm: AppWidgetManager,
301             widgetId: Int,
302             options: Bundle
303         ) {
304             val portrait: RemoteViews = relayoutWidget(context, wm, widgetId, options, true)
305             val landscape: RemoteViews = relayoutWidget(context, wm, widgetId, options, false)
306             val widget = RemoteViews(landscape, portrait)
307             wm.updateAppWidget(widgetId, widget)
308             wm.notifyAppWidgetViewDataChanged(widgetId, R.id.world_city_list)
309         }
310 
311         /**
312          * Compute optimal font and icon sizes offscreen for the given orientation.
313          */
314         private fun relayoutWidget(
315             context: Context,
316             wm: AppWidgetManager,
317             widgetId: Int,
318             options: Bundle?,
319             portrait: Boolean
320         ): RemoteViews {
321             // Create a remote view for the digital clock.
322             val packageName: String = context.getPackageName()
323             val rv = RemoteViews(packageName, R.layout.digital_widget)
324 
325             // Tapping on the widget opens the app (if not on the lock screen).
326             if (Utils.isWidgetClickable(wm, widgetId)) {
327                 val openApp = Intent(context, DeskClock::class.java)
328                 val pi: PendingIntent = PendingIntent.getActivity(context, 0, openApp, 0)
329                 rv.setOnClickPendingIntent(R.id.digital_widget, pi)
330             }
331 
332             // Configure child views of the remote view.
333             val dateFormat: CharSequence = getDateFormat(context)
334             rv.setCharSequence(R.id.date, "setFormat12Hour", dateFormat)
335             rv.setCharSequence(R.id.date, "setFormat24Hour", dateFormat)
336 
337             val nextAlarmTime: String? = Utils.getNextAlarm(context)
338             if (TextUtils.isEmpty(nextAlarmTime)) {
339                 rv.setViewVisibility(R.id.nextAlarm, GONE)
340                 rv.setViewVisibility(R.id.nextAlarmIcon, GONE)
341             } else {
342                 rv.setTextViewText(R.id.nextAlarm, nextAlarmTime)
343                 rv.setViewVisibility(R.id.nextAlarm, VISIBLE)
344                 rv.setViewVisibility(R.id.nextAlarmIcon, VISIBLE)
345             }
346 
347             val options = options ?: wm.getAppWidgetOptions(widgetId)
348 
349             // Fetch the widget size selected by the user.
350             val resources: Resources = context.getResources()
351             val density: Float = resources.getDisplayMetrics().density
352             val minWidthPx = (density * options.getInt(OPTION_APPWIDGET_MIN_WIDTH)).toInt()
353             val minHeightPx = (density * options.getInt(OPTION_APPWIDGET_MIN_HEIGHT)).toInt()
354             val maxWidthPx = (density * options.getInt(OPTION_APPWIDGET_MAX_WIDTH)).toInt()
355             val maxHeightPx = (density * options.getInt(OPTION_APPWIDGET_MAX_HEIGHT)).toInt()
356             val targetWidthPx = if (portrait) minWidthPx else maxWidthPx
357             val targetHeightPx = if (portrait) maxHeightPx else minHeightPx
358             val largestClockFontSizePx: Int =
359                     resources.getDimensionPixelSize(R.dimen.widget_max_clock_font_size)
360 
361             // Create a size template that describes the widget bounds.
362             val template = Sizes(targetWidthPx, targetHeightPx, largestClockFontSizePx)
363 
364             // Compute optimal font sizes and icon sizes to fit within the widget bounds.
365             val sizes = optimizeSizes(context, template, nextAlarmTime)
366             if (LOGGER.isVerboseLoggable) {
367                 LOGGER.v(sizes.toString())
368             }
369 
370             // Apply the computed sizes to the remote views.
371             rv.setImageViewBitmap(R.id.nextAlarmIcon, sizes.mIconBitmap)
372             rv.setTextViewTextSize(R.id.date, COMPLEX_UNIT_PX, sizes.mFontSizePx.toFloat())
373             rv.setTextViewTextSize(R.id.nextAlarm, COMPLEX_UNIT_PX, sizes.mFontSizePx.toFloat())
374             rv.setTextViewTextSize(R.id.clock, COMPLEX_UNIT_PX, sizes.mClockFontSizePx.toFloat())
375 
376             val smallestWorldCityListSizePx: Int =
377                     resources.getDimensionPixelSize(R.dimen.widget_min_world_city_list_size)
378             if (sizes.listHeight <= smallestWorldCityListSizePx) {
379                 // Insufficient space; hide the world city list.
380                 rv.setViewVisibility(R.id.world_city_list, GONE)
381             } else {
382                 // Set an adapter on the world city list. That adapter connects to a Service via intent.
383                 val intent = Intent(context, DigitalAppWidgetCityService::class.java)
384                 intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
385                 intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME)))
386                 rv.setRemoteAdapter(R.id.world_city_list, intent)
387                 rv.setViewVisibility(R.id.world_city_list, VISIBLE)
388 
389                 // Tapping on the widget opens the city selection activity (if not on the lock screen).
390                 if (Utils.isWidgetClickable(wm, widgetId)) {
391                     val selectCity = Intent(context, CitySelectionActivity::class.java)
392                     val pi: PendingIntent = PendingIntent.getActivity(context, 0, selectCity, 0)
393                     rv.setPendingIntentTemplate(R.id.world_city_list, pi)
394                 }
395             }
396 
397             return rv
398         }
399 
400         /**
401          * Inflate an offscreen copy of the widget views. Binary search through the range of sizes
402          * until the optimal sizes that fit within the widget bounds are located.
403          */
404         private fun optimizeSizes(
405             context: Context,
406             template: Sizes,
407             nextAlarmTime: String?
408         ): Sizes {
409             // Inflate a test layout to compute sizes at different font sizes.
410             val inflater: LayoutInflater = LayoutInflater.from(context)
411             @SuppressLint("InflateParams") val sizer: View =
412                     inflater.inflate(R.layout.digital_widget_sizer, null /* root */)
413 
414             // Configure the date to display the current date string.
415             val dateFormat: CharSequence = getDateFormat(context)
416             val date: TextClock = sizer.findViewById(R.id.date) as TextClock
417             date.setFormat12Hour(dateFormat)
418             date.setFormat24Hour(dateFormat)
419 
420             // Configure the next alarm views to display the next alarm time or be gone.
421             val nextAlarmIcon: TextView = sizer.findViewById(R.id.nextAlarmIcon) as TextView
422             val nextAlarm: TextView = sizer.findViewById(R.id.nextAlarm) as TextView
423             if (TextUtils.isEmpty(nextAlarmTime)) {
424                 nextAlarm.setVisibility(GONE)
425                 nextAlarmIcon.setVisibility(GONE)
426             } else {
427                 nextAlarm.setText(nextAlarmTime)
428                 nextAlarm.setVisibility(VISIBLE)
429                 nextAlarmIcon.setVisibility(VISIBLE)
430                 nextAlarmIcon.setTypeface(UiDataModel.uiDataModel.alarmIconTypeface)
431             }
432 
433             // Measure the widget at the largest possible size.
434             var high = measure(template, template.largestClockFontSizePx, sizer)
435             if (!high.hasViolations()) {
436                 return high
437             }
438 
439             // Measure the widget at the smallest possible size.
440             var low = measure(template, template.smallestClockFontSizePx, sizer)
441             if (low.hasViolations()) {
442                 return low
443             }
444 
445             // Binary search between the smallest and largest sizes until an optimum size is found.
446             while (low.clockFontSizePx != high.clockFontSizePx) {
447                 val midFontSize: Int = (low.clockFontSizePx + high.clockFontSizePx) / 2
448                 if (midFontSize == low.clockFontSizePx) {
449                     return low
450                 }
451                 val midSize = measure(template, midFontSize, sizer)
452                 if (midSize.hasViolations()) {
453                     high = midSize
454                 } else {
455                     low = midSize
456                 }
457             }
458 
459             return low
460         }
461 
462         private fun getAlarmManager(context: Context): AlarmManager {
463             return context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
464         }
465 
466         /**
467          * Compute all font and icon sizes based on the given `clockFontSize` and apply them to
468          * the offscreen `sizer` view. Measure the `sizer` view and return the resulting
469          * size measurements.
470          */
471         private fun measure(template: Sizes, clockFontSize: Int, sizer: View): Sizes {
472             // Create a copy of the given template sizes.
473             val measuredSizes = template.newSize()
474 
475             // Configure the clock to display the widest time string.
476             val date: TextClock = sizer.findViewById(R.id.date) as TextClock
477             val clock: TextClock = sizer.findViewById(R.id.clock) as TextClock
478             val nextAlarm: TextView = sizer.findViewById(R.id.nextAlarm) as TextView
479             val nextAlarmIcon: TextView = sizer.findViewById(R.id.nextAlarmIcon) as TextView
480 
481             // Adjust the font sizes.
482             measuredSizes.clockFontSizePx = clockFontSize
483             clock.setText(getLongestTimeString(clock))
484             clock.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mClockFontSizePx.toFloat())
485             date.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mFontSizePx.toFloat())
486             nextAlarm.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mFontSizePx.toFloat())
487             nextAlarmIcon.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mIconFontSizePx.toFloat())
488             nextAlarmIcon
489                     .setPadding(measuredSizes.mIconPaddingPx, 0, measuredSizes.mIconPaddingPx, 0)
490 
491             // Measure and layout the sizer.
492             val widthSize: Int = View.MeasureSpec.getSize(measuredSizes.mTargetWidthPx)
493             val heightSize: Int = View.MeasureSpec.getSize(measuredSizes.mTargetHeightPx)
494             val widthMeasureSpec: Int = View.MeasureSpec.makeMeasureSpec(widthSize, UNSPECIFIED)
495             val heightMeasureSpec: Int = View.MeasureSpec.makeMeasureSpec(heightSize, UNSPECIFIED)
496             sizer.measure(widthMeasureSpec, heightMeasureSpec)
497             sizer.layout(0, 0, sizer.getMeasuredWidth(), sizer.getMeasuredHeight())
498 
499             // Copy the measurements into the result object.
500             measuredSizes.mMeasuredWidthPx = sizer.getMeasuredWidth()
501             measuredSizes.mMeasuredHeightPx = sizer.getMeasuredHeight()
502             measuredSizes.mMeasuredTextClockWidthPx = clock.getMeasuredWidth()
503             measuredSizes.mMeasuredTextClockHeightPx = clock.getMeasuredHeight()
504 
505             // If an alarm icon is required, generate one from the TextView with the special font.
506             if (nextAlarmIcon.getVisibility() == VISIBLE) {
507                 measuredSizes.mIconBitmap = Utils.createBitmap(nextAlarmIcon)
508             }
509 
510             return measuredSizes
511         }
512 
513         /**
514          * @return "11:59" or "23:59" in the current locale
515          */
516         private fun getLongestTimeString(clock: TextClock): CharSequence {
517             val format: CharSequence = if (clock.is24HourModeEnabled()) {
518                 clock.getFormat24Hour()
519             } else {
520                 clock.getFormat12Hour()
521             }
522             val longestPMTime = Calendar.getInstance()
523             longestPMTime[0, 0, 0, 23] = 59
524             return DateFormat.format(format, longestPMTime)
525         }
526 
527         /**
528          * @return the locale-specific date pattern
529          */
530         private fun getDateFormat(context: Context): String {
531             val locale = Locale.getDefault()
532             val skeleton: String = context.getString(R.string.abbrev_wday_month_day_no_year)
533             return DateFormat.getBestDateTimePattern(locale, skeleton)
534         }
535     }
536 }