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 = 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 until
402          * the optimal sizes that fit within the widget bounds are located.
403          */
404         private fun optimizeSizes(context: Context, template: Sizes, nextAlarmTime: String): Sizes {
405             // Inflate a test layout to compute sizes at different font sizes.
406             val inflater: LayoutInflater = LayoutInflater.from(context)
407             @SuppressLint("InflateParams") val sizer: View =
408                     inflater.inflate(R.layout.digital_widget_sizer, null /* root */)
409 
410             // Configure the date to display the current date string.
411             val dateFormat: CharSequence = getDateFormat(context)
412             val date: TextClock = sizer.findViewById(R.id.date) as TextClock
413             date.setFormat12Hour(dateFormat)
414             date.setFormat24Hour(dateFormat)
415 
416             // Configure the next alarm views to display the next alarm time or be gone.
417             val nextAlarmIcon: TextView = sizer.findViewById(R.id.nextAlarmIcon) as TextView
418             val nextAlarm: TextView = sizer.findViewById(R.id.nextAlarm) as TextView
419             if (TextUtils.isEmpty(nextAlarmTime)) {
420                 nextAlarm.setVisibility(GONE)
421                 nextAlarmIcon.setVisibility(GONE)
422             } else {
423                 nextAlarm.setText(nextAlarmTime)
424                 nextAlarm.setVisibility(VISIBLE)
425                 nextAlarmIcon.setVisibility(VISIBLE)
426                 nextAlarmIcon.setTypeface(UiDataModel.uiDataModel.alarmIconTypeface)
427             }
428 
429             // Measure the widget at the largest possible size.
430             var high = measure(template, template.largestClockFontSizePx, sizer)
431             if (!high.hasViolations()) {
432                 return high
433             }
434 
435             // Measure the widget at the smallest possible size.
436             var low = measure(template, template.smallestClockFontSizePx, sizer)
437             if (low.hasViolations()) {
438                 return low
439             }
440 
441             // Binary search between the smallest and largest sizes until an optimum size is found.
442             while (low.clockFontSizePx != high.clockFontSizePx) {
443                 val midFontSize: Int = (low.clockFontSizePx + high.clockFontSizePx) / 2
444                 if (midFontSize == low.clockFontSizePx) {
445                     return low
446                 }
447                 val midSize = measure(template, midFontSize, sizer)
448                 if (midSize.hasViolations()) {
449                     high = midSize
450                 } else {
451                     low = midSize
452                 }
453             }
454 
455             return low
456         }
457 
458         private fun getAlarmManager(context: Context): AlarmManager {
459             return context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
460         }
461 
462         /**
463          * Compute all font and icon sizes based on the given `clockFontSize` and apply them to
464          * the offscreen `sizer` view. Measure the `sizer` view and return the resulting
465          * size measurements.
466          */
467         private fun measure(template: Sizes, clockFontSize: Int, sizer: View): Sizes {
468             // Create a copy of the given template sizes.
469             val measuredSizes = template.newSize()
470 
471             // Configure the clock to display the widest time string.
472             val date: TextClock = sizer.findViewById(R.id.date) as TextClock
473             val clock: TextClock = sizer.findViewById(R.id.clock) as TextClock
474             val nextAlarm: TextView = sizer.findViewById(R.id.nextAlarm) as TextView
475             val nextAlarmIcon: TextView = sizer.findViewById(R.id.nextAlarmIcon) as TextView
476 
477             // Adjust the font sizes.
478             measuredSizes.clockFontSizePx = clockFontSize
479             clock.setText(getLongestTimeString(clock))
480             clock.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mClockFontSizePx.toFloat())
481             date.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mFontSizePx.toFloat())
482             nextAlarm.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mFontSizePx.toFloat())
483             nextAlarmIcon.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mIconFontSizePx.toFloat())
484             nextAlarmIcon
485                     .setPadding(measuredSizes.mIconPaddingPx, 0, measuredSizes.mIconPaddingPx, 0)
486 
487             // Measure and layout the sizer.
488             val widthSize: Int = View.MeasureSpec.getSize(measuredSizes.mTargetWidthPx)
489             val heightSize: Int = View.MeasureSpec.getSize(measuredSizes.mTargetHeightPx)
490             val widthMeasureSpec: Int = View.MeasureSpec.makeMeasureSpec(widthSize, UNSPECIFIED)
491             val heightMeasureSpec: Int = View.MeasureSpec.makeMeasureSpec(heightSize, UNSPECIFIED)
492             sizer.measure(widthMeasureSpec, heightMeasureSpec)
493             sizer.layout(0, 0, sizer.getMeasuredWidth(), sizer.getMeasuredHeight())
494 
495             // Copy the measurements into the result object.
496             measuredSizes.mMeasuredWidthPx = sizer.getMeasuredWidth()
497             measuredSizes.mMeasuredHeightPx = sizer.getMeasuredHeight()
498             measuredSizes.mMeasuredTextClockWidthPx = clock.getMeasuredWidth()
499             measuredSizes.mMeasuredTextClockHeightPx = clock.getMeasuredHeight()
500 
501             // If an alarm icon is required, generate one from the TextView with the special font.
502             if (nextAlarmIcon.getVisibility() == VISIBLE) {
503                 measuredSizes.mIconBitmap = Utils.createBitmap(nextAlarmIcon)
504             }
505 
506             return measuredSizes
507         }
508 
509         /**
510          * @return "11:59" or "23:59" in the current locale
511          */
512         private fun getLongestTimeString(clock: TextClock): CharSequence {
513             val format: CharSequence = if (clock.is24HourModeEnabled()) {
514                 clock.getFormat24Hour()
515             } else {
516                 clock.getFormat12Hour()
517             }
518             val longestPMTime = Calendar.getInstance()
519             longestPMTime[0, 0, 0, 23] = 59
520             return DateFormat.format(format, longestPMTime)
521         }
522 
523         /**
524          * @return the locale-specific date pattern
525          */
526         private fun getDateFormat(context: Context): String {
527             val locale = Locale.getDefault()
528             val skeleton: String = context.getString(R.string.abbrev_wday_month_day_no_year)
529             return DateFormat.getBestDateTimePattern(locale, skeleton)
530         }
531     }
532 }