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 }