| /* |
| * Copyright (C) 2020 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.alarmclock |
| |
| import android.annotation.SuppressLint |
| import android.app.AlarmManager |
| import android.app.AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED |
| import android.app.PendingIntent |
| import android.app.PendingIntent.FLAG_NO_CREATE |
| import android.app.PendingIntent.FLAG_UPDATE_CURRENT |
| import android.appwidget.AppWidgetManager |
| import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT |
| import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH |
| import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT |
| import android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH |
| import android.appwidget.AppWidgetProvider |
| import android.content.ComponentName |
| import android.content.Context |
| import android.content.Intent |
| import android.content.Intent.ACTION_DATE_CHANGED |
| import android.content.Intent.ACTION_LOCALE_CHANGED |
| import android.content.Intent.ACTION_SCREEN_ON |
| import android.content.Intent.ACTION_TIMEZONE_CHANGED |
| import android.content.Intent.ACTION_TIME_CHANGED |
| import android.content.res.Resources |
| import android.graphics.Bitmap |
| import android.net.Uri |
| import android.os.Bundle |
| import android.text.TextUtils |
| import android.text.format.DateFormat |
| import android.util.ArraySet |
| import android.util.TypedValue.COMPLEX_UNIT_PX |
| import android.view.LayoutInflater |
| import android.view.View |
| import android.view.View.GONE |
| import android.view.View.MeasureSpec.UNSPECIFIED |
| import android.view.View.VISIBLE |
| import android.widget.RemoteViews |
| import android.widget.TextClock |
| import android.widget.TextView |
| |
| import com.android.deskclock.DeskClock |
| import com.android.deskclock.LogUtils |
| import com.android.deskclock.R |
| import com.android.deskclock.Utils |
| import com.android.deskclock.alarms.AlarmStateManager |
| import com.android.deskclock.data.DataModel |
| import com.android.deskclock.uidata.UiDataModel |
| import com.android.deskclock.worldclock.CitySelectionActivity |
| |
| import java.util.Calendar |
| import java.util.Date |
| import java.util.Locale |
| import java.util.TimeZone |
| |
| /** |
| * This provider produces a widget resembling one of the formats below. |
| * |
| * If an alarm is scheduled to ring in the future: |
| * <pre> |
| * 12:59 AM |
| * WED, FEB 3 ⏰ THU 9:30 AM |
| * </pre> |
| * |
| * If no alarm is scheduled to ring in the future: |
| * <pre> |
| * 12:59 AM |
| * WED, FEB 3 |
| * </pre> |
| * |
| * This widget is scaling the font sizes to fit within the widget bounds chosen by the user without |
| * any clipping. To do so it measures layouts offscreen using a range of font sizes in order to |
| * choose optimal values. |
| */ |
| class DigitalAppWidgetProvider : AppWidgetProvider() { |
| |
| override fun onEnabled(context: Context) { |
| super.onEnabled(context) |
| |
| // Schedule the day-change callback if necessary. |
| updateDayChangeCallback(context) |
| } |
| |
| override fun onDisabled(context: Context) { |
| super.onDisabled(context) |
| |
| // Remove any scheduled day-change callback. |
| removeDayChangeCallback(context) |
| } |
| |
| override fun onReceive(context: Context, intent: Intent) { |
| LOGGER.i("onReceive: $intent") |
| super.onReceive(context, intent) |
| |
| val wm: AppWidgetManager = AppWidgetManager.getInstance(context) ?: return |
| |
| val provider = ComponentName(context, javaClass) |
| val widgetIds: IntArray = wm.getAppWidgetIds(provider) |
| |
| val action: String? = intent.action |
| when (action) { |
| ACTION_NEXT_ALARM_CLOCK_CHANGED, |
| ACTION_DATE_CHANGED, |
| ACTION_LOCALE_CHANGED, |
| ACTION_SCREEN_ON, |
| ACTION_TIME_CHANGED, |
| ACTION_TIMEZONE_CHANGED, |
| AlarmStateManager.ACTION_ALARM_CHANGED, |
| ACTION_ON_DAY_CHANGE, |
| DataModel.ACTION_WORLD_CITIES_CHANGED -> widgetIds.forEach { widgetId -> |
| relayoutWidget(context, wm, widgetId, wm.getAppWidgetOptions(widgetId)) |
| } |
| } |
| |
| val dm = DataModel.dataModel |
| dm.updateWidgetCount(javaClass, widgetIds.size, R.string.category_digital_widget) |
| |
| if (widgetIds.size > 0) { |
| updateDayChangeCallback(context) |
| } |
| } |
| |
| /** |
| * Called when widgets must provide remote views. |
| */ |
| override fun onUpdate(context: Context, wm: AppWidgetManager, widgetIds: IntArray) { |
| super.onUpdate(context, wm, widgetIds) |
| |
| widgetIds.forEach { widgetId -> |
| relayoutWidget(context, wm, widgetId, wm.getAppWidgetOptions(widgetId)) |
| } |
| } |
| |
| /** |
| * Called when the app widget changes sizes. |
| */ |
| override fun onAppWidgetOptionsChanged( |
| context: Context, |
| wm: AppWidgetManager?, |
| widgetId: Int, |
| options: Bundle |
| ) { |
| super.onAppWidgetOptionsChanged(context, wm, widgetId, options) |
| |
| // Scale the fonts of the clock to fit inside the new size |
| relayoutWidget(context, AppWidgetManager.getInstance(context), widgetId, options) |
| } |
| |
| /** |
| * Remove the existing day-change callback if it is not needed (no selected cities exist). |
| * Add the day-change callback if it is needed (selected cities exist). |
| */ |
| private fun updateDayChangeCallback(context: Context) { |
| val dm = DataModel.dataModel |
| val selectedCities = dm.selectedCities |
| val showHomeClock = dm.showHomeClock |
| if (selectedCities.isEmpty() && !showHomeClock) { |
| // Remove the existing day-change callback. |
| removeDayChangeCallback(context) |
| return |
| } |
| |
| // Look up the time at which the next day change occurs across all timezones. |
| val zones: MutableSet<TimeZone> = ArraySet(selectedCities.size + 2) |
| zones.add(TimeZone.getDefault()) |
| if (showHomeClock) { |
| zones.add(dm.homeCity.timeZone) |
| } |
| selectedCities.forEach { city -> |
| zones.add(city.timeZone) |
| } |
| val nextDay = Utils.getNextDay(Date(), zones) |
| |
| // Schedule the next day-change callback; at least one city is displayed. |
| val pi: PendingIntent = |
| PendingIntent.getBroadcast(context, 0, DAY_CHANGE_INTENT, FLAG_UPDATE_CURRENT) |
| getAlarmManager(context).setExact(AlarmManager.RTC, nextDay.time, pi) |
| } |
| |
| /** |
| * Remove the existing day-change callback. |
| */ |
| private fun removeDayChangeCallback(context: Context) { |
| val pi: PendingIntent? = |
| PendingIntent.getBroadcast(context, 0, DAY_CHANGE_INTENT, FLAG_NO_CREATE) |
| if (pi != null) { |
| getAlarmManager(context).cancel(pi) |
| pi.cancel() |
| } |
| } |
| |
| /** |
| * This class stores the target size of the widget as well as the measured size using a given |
| * clock font size. All other fonts and icons are scaled proportional to the clock font. |
| */ |
| private class Sizes( |
| val mTargetWidthPx: Int, |
| val mTargetHeightPx: Int, |
| val largestClockFontSizePx: Int |
| ) { |
| val smallestClockFontSizePx = 1 |
| var mIconBitmap: Bitmap? = null |
| |
| var mMeasuredWidthPx = 0 |
| var mMeasuredHeightPx = 0 |
| var mMeasuredTextClockWidthPx = 0 |
| var mMeasuredTextClockHeightPx = 0 |
| |
| /** The size of the font to use on the date / next alarm time fields. */ |
| var mFontSizePx = 0 |
| |
| /** The size of the font to use on the clock field. */ |
| var mClockFontSizePx = 0 |
| |
| var mIconFontSizePx = 0 |
| var mIconPaddingPx = 0 |
| |
| var clockFontSizePx: Int |
| get() = mClockFontSizePx |
| set(clockFontSizePx) { |
| mClockFontSizePx = clockFontSizePx |
| mFontSizePx = Math.max(1, Math.round(clockFontSizePx / 7.5f)) |
| mIconFontSizePx = (mFontSizePx * 1.4f).toInt() |
| mIconPaddingPx = mFontSizePx / 3 |
| } |
| |
| /** |
| * @return the amount of widget height available to the world cities list |
| */ |
| val listHeight: Int |
| get() = mTargetHeightPx - mMeasuredHeightPx |
| |
| fun hasViolations(): Boolean { |
| return mMeasuredWidthPx > mTargetWidthPx || mMeasuredHeightPx > mTargetHeightPx |
| } |
| |
| fun newSize(): Sizes { |
| return Sizes(mTargetWidthPx, mTargetHeightPx, largestClockFontSizePx) |
| } |
| |
| override fun toString(): String { |
| val builder = StringBuilder(1000) |
| builder.append("\n") |
| append(builder, "Target dimensions: %dpx x %dpx\n", mTargetWidthPx, mTargetHeightPx) |
| append(builder, "Last valid widget container measurement: %dpx x %dpx\n", |
| mMeasuredWidthPx, mMeasuredHeightPx) |
| append(builder, "Last text clock measurement: %dpx x %dpx\n", |
| mMeasuredTextClockWidthPx, mMeasuredTextClockHeightPx) |
| if (mMeasuredWidthPx > mTargetWidthPx) { |
| append(builder, "Measured width %dpx exceeded widget width %dpx\n", |
| mMeasuredWidthPx, mTargetWidthPx) |
| } |
| if (mMeasuredHeightPx > mTargetHeightPx) { |
| append(builder, "Measured height %dpx exceeded widget height %dpx\n", |
| mMeasuredHeightPx, mTargetHeightPx) |
| } |
| append(builder, "Clock font: %dpx\n", mClockFontSizePx) |
| return builder.toString() |
| } |
| |
| companion object { |
| private fun append(builder: StringBuilder, format: String, vararg args: Any) { |
| builder.append(String.format(Locale.ENGLISH, format, *args)) |
| } |
| } |
| } |
| |
| companion object { |
| private val LOGGER = LogUtils.Logger("DigitalWidgetProvider") |
| |
| /** |
| * Intent action used for refreshing a world city display when any of them changes days or when |
| * the default TimeZone changes days. This affects the widget display because the day-of-week is |
| * only visible when the world city day-of-week differs from the default TimeZone's day-of-week. |
| */ |
| private const val ACTION_ON_DAY_CHANGE = "com.android.deskclock.ON_DAY_CHANGE" |
| |
| /** Intent used to deliver the [.ACTION_ON_DAY_CHANGE] callback. */ |
| private val DAY_CHANGE_INTENT: Intent = Intent(ACTION_ON_DAY_CHANGE) |
| |
| /** |
| * Compute optimal font and icon sizes offscreen for both portrait and landscape orientations |
| * using the last known widget size and apply them to the widget. |
| */ |
| private fun relayoutWidget( |
| context: Context, |
| wm: AppWidgetManager, |
| widgetId: Int, |
| options: Bundle |
| ) { |
| val portrait: RemoteViews = relayoutWidget(context, wm, widgetId, options, true) |
| val landscape: RemoteViews = relayoutWidget(context, wm, widgetId, options, false) |
| val widget = RemoteViews(landscape, portrait) |
| wm.updateAppWidget(widgetId, widget) |
| wm.notifyAppWidgetViewDataChanged(widgetId, R.id.world_city_list) |
| } |
| |
| /** |
| * Compute optimal font and icon sizes offscreen for the given orientation. |
| */ |
| private fun relayoutWidget( |
| context: Context, |
| wm: AppWidgetManager, |
| widgetId: Int, |
| options: Bundle?, |
| portrait: Boolean |
| ): RemoteViews { |
| // Create a remote view for the digital clock. |
| val packageName: String = context.getPackageName() |
| val rv = RemoteViews(packageName, R.layout.digital_widget) |
| |
| // Tapping on the widget opens the app (if not on the lock screen). |
| if (Utils.isWidgetClickable(wm, widgetId)) { |
| val openApp = Intent(context, DeskClock::class.java) |
| val pi: PendingIntent = PendingIntent.getActivity(context, 0, openApp, 0) |
| rv.setOnClickPendingIntent(R.id.digital_widget, pi) |
| } |
| |
| // Configure child views of the remote view. |
| val dateFormat: CharSequence = getDateFormat(context) |
| rv.setCharSequence(R.id.date, "setFormat12Hour", dateFormat) |
| rv.setCharSequence(R.id.date, "setFormat24Hour", dateFormat) |
| |
| val nextAlarmTime = Utils.getNextAlarm(context) |
| if (TextUtils.isEmpty(nextAlarmTime)) { |
| rv.setViewVisibility(R.id.nextAlarm, GONE) |
| rv.setViewVisibility(R.id.nextAlarmIcon, GONE) |
| } else { |
| rv.setTextViewText(R.id.nextAlarm, nextAlarmTime) |
| rv.setViewVisibility(R.id.nextAlarm, VISIBLE) |
| rv.setViewVisibility(R.id.nextAlarmIcon, VISIBLE) |
| } |
| |
| val options = options ?: wm.getAppWidgetOptions(widgetId) |
| |
| // Fetch the widget size selected by the user. |
| val resources: Resources = context.getResources() |
| val density: Float = resources.getDisplayMetrics().density |
| val minWidthPx = (density * options.getInt(OPTION_APPWIDGET_MIN_WIDTH)).toInt() |
| val minHeightPx = (density * options.getInt(OPTION_APPWIDGET_MIN_HEIGHT)).toInt() |
| val maxWidthPx = (density * options.getInt(OPTION_APPWIDGET_MAX_WIDTH)).toInt() |
| val maxHeightPx = (density * options.getInt(OPTION_APPWIDGET_MAX_HEIGHT)).toInt() |
| val targetWidthPx = if (portrait) minWidthPx else maxWidthPx |
| val targetHeightPx = if (portrait) maxHeightPx else minHeightPx |
| val largestClockFontSizePx: Int = |
| resources.getDimensionPixelSize(R.dimen.widget_max_clock_font_size) |
| |
| // Create a size template that describes the widget bounds. |
| val template = Sizes(targetWidthPx, targetHeightPx, largestClockFontSizePx) |
| |
| // Compute optimal font sizes and icon sizes to fit within the widget bounds. |
| val sizes = optimizeSizes(context, template, nextAlarmTime) |
| if (LOGGER.isVerboseLoggable) { |
| LOGGER.v(sizes.toString()) |
| } |
| |
| // Apply the computed sizes to the remote views. |
| rv.setImageViewBitmap(R.id.nextAlarmIcon, sizes.mIconBitmap) |
| rv.setTextViewTextSize(R.id.date, COMPLEX_UNIT_PX, sizes.mFontSizePx.toFloat()) |
| rv.setTextViewTextSize(R.id.nextAlarm, COMPLEX_UNIT_PX, sizes.mFontSizePx.toFloat()) |
| rv.setTextViewTextSize(R.id.clock, COMPLEX_UNIT_PX, sizes.mClockFontSizePx.toFloat()) |
| |
| val smallestWorldCityListSizePx: Int = |
| resources.getDimensionPixelSize(R.dimen.widget_min_world_city_list_size) |
| if (sizes.listHeight <= smallestWorldCityListSizePx) { |
| // Insufficient space; hide the world city list. |
| rv.setViewVisibility(R.id.world_city_list, GONE) |
| } else { |
| // Set an adapter on the world city list. That adapter connects to a Service via intent. |
| val intent = Intent(context, DigitalAppWidgetCityService::class.java) |
| intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId) |
| intent.setData(Uri.parse(intent.toUri(Intent.URI_INTENT_SCHEME))) |
| rv.setRemoteAdapter(R.id.world_city_list, intent) |
| rv.setViewVisibility(R.id.world_city_list, VISIBLE) |
| |
| // Tapping on the widget opens the city selection activity (if not on the lock screen). |
| if (Utils.isWidgetClickable(wm, widgetId)) { |
| val selectCity = Intent(context, CitySelectionActivity::class.java) |
| val pi: PendingIntent = PendingIntent.getActivity(context, 0, selectCity, 0) |
| rv.setPendingIntentTemplate(R.id.world_city_list, pi) |
| } |
| } |
| |
| return rv |
| } |
| |
| /** |
| * Inflate an offscreen copy of the widget views. Binary search through the range of sizes until |
| * the optimal sizes that fit within the widget bounds are located. |
| */ |
| private fun optimizeSizes(context: Context, template: Sizes, nextAlarmTime: String): Sizes { |
| // Inflate a test layout to compute sizes at different font sizes. |
| val inflater: LayoutInflater = LayoutInflater.from(context) |
| @SuppressLint("InflateParams") val sizer: View = |
| inflater.inflate(R.layout.digital_widget_sizer, null /* root */) |
| |
| // Configure the date to display the current date string. |
| val dateFormat: CharSequence = getDateFormat(context) |
| val date: TextClock = sizer.findViewById(R.id.date) as TextClock |
| date.setFormat12Hour(dateFormat) |
| date.setFormat24Hour(dateFormat) |
| |
| // Configure the next alarm views to display the next alarm time or be gone. |
| val nextAlarmIcon: TextView = sizer.findViewById(R.id.nextAlarmIcon) as TextView |
| val nextAlarm: TextView = sizer.findViewById(R.id.nextAlarm) as TextView |
| if (TextUtils.isEmpty(nextAlarmTime)) { |
| nextAlarm.setVisibility(GONE) |
| nextAlarmIcon.setVisibility(GONE) |
| } else { |
| nextAlarm.setText(nextAlarmTime) |
| nextAlarm.setVisibility(VISIBLE) |
| nextAlarmIcon.setVisibility(VISIBLE) |
| nextAlarmIcon.setTypeface(UiDataModel.uiDataModel.alarmIconTypeface) |
| } |
| |
| // Measure the widget at the largest possible size. |
| var high = measure(template, template.largestClockFontSizePx, sizer) |
| if (!high.hasViolations()) { |
| return high |
| } |
| |
| // Measure the widget at the smallest possible size. |
| var low = measure(template, template.smallestClockFontSizePx, sizer) |
| if (low.hasViolations()) { |
| return low |
| } |
| |
| // Binary search between the smallest and largest sizes until an optimum size is found. |
| while (low.clockFontSizePx != high.clockFontSizePx) { |
| val midFontSize: Int = (low.clockFontSizePx + high.clockFontSizePx) / 2 |
| if (midFontSize == low.clockFontSizePx) { |
| return low |
| } |
| val midSize = measure(template, midFontSize, sizer) |
| if (midSize.hasViolations()) { |
| high = midSize |
| } else { |
| low = midSize |
| } |
| } |
| |
| return low |
| } |
| |
| private fun getAlarmManager(context: Context): AlarmManager { |
| return context.getSystemService(Context.ALARM_SERVICE) as AlarmManager |
| } |
| |
| /** |
| * Compute all font and icon sizes based on the given `clockFontSize` and apply them to |
| * the offscreen `sizer` view. Measure the `sizer` view and return the resulting |
| * size measurements. |
| */ |
| private fun measure(template: Sizes, clockFontSize: Int, sizer: View): Sizes { |
| // Create a copy of the given template sizes. |
| val measuredSizes = template.newSize() |
| |
| // Configure the clock to display the widest time string. |
| val date: TextClock = sizer.findViewById(R.id.date) as TextClock |
| val clock: TextClock = sizer.findViewById(R.id.clock) as TextClock |
| val nextAlarm: TextView = sizer.findViewById(R.id.nextAlarm) as TextView |
| val nextAlarmIcon: TextView = sizer.findViewById(R.id.nextAlarmIcon) as TextView |
| |
| // Adjust the font sizes. |
| measuredSizes.clockFontSizePx = clockFontSize |
| clock.setText(getLongestTimeString(clock)) |
| clock.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mClockFontSizePx.toFloat()) |
| date.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mFontSizePx.toFloat()) |
| nextAlarm.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mFontSizePx.toFloat()) |
| nextAlarmIcon.setTextSize(COMPLEX_UNIT_PX, measuredSizes.mIconFontSizePx.toFloat()) |
| nextAlarmIcon |
| .setPadding(measuredSizes.mIconPaddingPx, 0, measuredSizes.mIconPaddingPx, 0) |
| |
| // Measure and layout the sizer. |
| val widthSize: Int = View.MeasureSpec.getSize(measuredSizes.mTargetWidthPx) |
| val heightSize: Int = View.MeasureSpec.getSize(measuredSizes.mTargetHeightPx) |
| val widthMeasureSpec: Int = View.MeasureSpec.makeMeasureSpec(widthSize, UNSPECIFIED) |
| val heightMeasureSpec: Int = View.MeasureSpec.makeMeasureSpec(heightSize, UNSPECIFIED) |
| sizer.measure(widthMeasureSpec, heightMeasureSpec) |
| sizer.layout(0, 0, sizer.getMeasuredWidth(), sizer.getMeasuredHeight()) |
| |
| // Copy the measurements into the result object. |
| measuredSizes.mMeasuredWidthPx = sizer.getMeasuredWidth() |
| measuredSizes.mMeasuredHeightPx = sizer.getMeasuredHeight() |
| measuredSizes.mMeasuredTextClockWidthPx = clock.getMeasuredWidth() |
| measuredSizes.mMeasuredTextClockHeightPx = clock.getMeasuredHeight() |
| |
| // If an alarm icon is required, generate one from the TextView with the special font. |
| if (nextAlarmIcon.getVisibility() == VISIBLE) { |
| measuredSizes.mIconBitmap = Utils.createBitmap(nextAlarmIcon) |
| } |
| |
| return measuredSizes |
| } |
| |
| /** |
| * @return "11:59" or "23:59" in the current locale |
| */ |
| private fun getLongestTimeString(clock: TextClock): CharSequence { |
| val format: CharSequence = if (clock.is24HourModeEnabled()) { |
| clock.getFormat24Hour() |
| } else { |
| clock.getFormat12Hour() |
| } |
| val longestPMTime = Calendar.getInstance() |
| longestPMTime[0, 0, 0, 23] = 59 |
| return DateFormat.format(format, longestPMTime) |
| } |
| |
| /** |
| * @return the locale-specific date pattern |
| */ |
| private fun getDateFormat(context: Context): String { |
| val locale = Locale.getDefault() |
| val skeleton: String = context.getString(R.string.abbrev_wday_month_day_no_year) |
| return DateFormat.getBestDateTimePattern(locale, skeleton) |
| } |
| } |
| } |