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 }