| /* |
| * Copyright (C) 2015 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.deskclock; |
| |
| import static android.app.PendingIntent.FLAG_IMMUTABLE; |
| import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; |
| import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_HOST_CATEGORY; |
| import static android.appwidget.AppWidgetProviderInfo.WIDGET_CATEGORY_KEYGUARD; |
| import static android.content.res.Configuration.ORIENTATION_LANDSCAPE; |
| import static android.content.res.Configuration.ORIENTATION_PORTRAIT; |
| |
| import android.annotation.SuppressLint; |
| import android.app.AlarmManager; |
| import android.app.AlarmManager.AlarmClockInfo; |
| import android.app.PendingIntent; |
| import android.appwidget.AppWidgetManager; |
| import android.content.ContentResolver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.graphics.Color; |
| import android.graphics.Paint; |
| import android.graphics.PorterDuff; |
| import android.graphics.PorterDuffColorFilter; |
| import android.graphics.Typeface; |
| import android.net.Uri; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.Looper; |
| import android.text.Spannable; |
| import android.text.SpannableString; |
| import android.text.TextUtils; |
| import android.text.format.DateFormat; |
| import android.text.format.DateUtils; |
| import android.text.style.RelativeSizeSpan; |
| import android.text.style.StyleSpan; |
| import android.text.style.TypefaceSpan; |
| import android.util.ArraySet; |
| import android.view.Gravity; |
| import android.view.View; |
| import android.widget.LinearLayout; |
| import android.widget.TextClock; |
| import android.widget.TextView; |
| |
| import androidx.annotation.AnyRes; |
| import androidx.annotation.DrawableRes; |
| import androidx.annotation.NonNull; |
| import androidx.annotation.StringRes; |
| import androidx.core.view.AccessibilityDelegateCompat; |
| import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; |
| import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; |
| import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat; |
| |
| import com.android.deskclock.data.DataModel; |
| import com.android.deskclock.provider.AlarmInstance; |
| import com.android.deskclock.uidata.UiDataModel; |
| |
| import java.text.NumberFormat; |
| import java.text.SimpleDateFormat; |
| import java.util.Calendar; |
| import java.util.Collection; |
| import java.util.Date; |
| import java.util.Locale; |
| import java.util.TimeZone; |
| |
| public class Utils { |
| |
| /** |
| * {@link Uri} signifying the "silent" ringtone. |
| */ |
| public static final Uri RINGTONE_SILENT = Uri.EMPTY; |
| |
| public static void enforceMainLooper() { |
| if (Looper.getMainLooper() != Looper.myLooper()) { |
| throw new IllegalAccessError("May only call from main thread."); |
| } |
| } |
| |
| public static void enforceNotMainLooper() { |
| if (Looper.getMainLooper() == Looper.myLooper()) { |
| throw new IllegalAccessError("May not call from main thread."); |
| } |
| } |
| |
| public static int indexOf(Object[] array, Object item) { |
| for (int i = 0; i < array.length; i++) { |
| if (array[i].equals(item)) { |
| return i; |
| } |
| } |
| return -1; |
| } |
| |
| /** |
| * @param resourceId identifies an application resource |
| * @return the Uri by which the application resource is accessed |
| */ |
| public static Uri getResourceUri(Context context, @AnyRes int resourceId) { |
| return new Uri.Builder() |
| .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) |
| .authority(context.getPackageName()) |
| .path(String.valueOf(resourceId)) |
| .build(); |
| } |
| |
| /** |
| * @param view the scrollable view to test |
| * @return {@code true} iff the {@code view} content is currently scrolled to the top |
| */ |
| public static boolean isScrolledToTop(View view) { |
| return !view.canScrollVertically(-1); |
| } |
| |
| /** |
| * Configure the clock that is visible to display seconds. The clock that is not visible never |
| * displays seconds to avoid it scheduling unnecessary ticking runnables. |
| */ |
| public static void setClockSecondsEnabled(TextClock digitalClock, AnalogClock analogClock) { |
| final boolean displaySeconds = DataModel.getDataModel().getDisplayClockSeconds(); |
| final DataModel.ClockStyle clockStyle = DataModel.getDataModel().getClockStyle(); |
| switch (clockStyle) { |
| case ANALOG: |
| setTimeFormat(digitalClock, false); |
| analogClock.enableSeconds(displaySeconds); |
| return; |
| case DIGITAL: |
| analogClock.enableSeconds(false); |
| setTimeFormat(digitalClock, displaySeconds); |
| return; |
| } |
| |
| throw new IllegalStateException("unexpected clock style: " + clockStyle); |
| } |
| |
| /** |
| * Set whether the digital or analog clock should be displayed in the application. |
| * Returns the view to be displayed. |
| */ |
| public static void setClockStyle(View digitalClock, View analogClock) { |
| final DataModel.ClockStyle clockStyle = DataModel.getDataModel().getClockStyle(); |
| switch (clockStyle) { |
| case ANALOG: |
| digitalClock.setVisibility(View.GONE); |
| analogClock.setVisibility(View.VISIBLE); |
| return; |
| case DIGITAL: |
| digitalClock.setVisibility(View.VISIBLE); |
| analogClock.setVisibility(View.GONE); |
| return; |
| } |
| |
| throw new IllegalStateException("unexpected clock style: " + clockStyle); |
| } |
| |
| /** |
| * For screensavers to set whether the digital or analog clock should be displayed. |
| * Returns the view to be displayed. |
| */ |
| public static void setScreensaverClockStyle(View digitalClock, View analogClock) { |
| final DataModel.ClockStyle clockStyle = DataModel.getDataModel().getScreensaverClockStyle(); |
| switch (clockStyle) { |
| case ANALOG: |
| digitalClock.setVisibility(View.GONE); |
| analogClock.setVisibility(View.VISIBLE); |
| return; |
| case DIGITAL: |
| digitalClock.setVisibility(View.VISIBLE); |
| analogClock.setVisibility(View.GONE); |
| return; |
| } |
| |
| throw new IllegalStateException("unexpected clock style: " + clockStyle); |
| } |
| |
| /** |
| * For screensavers to dim the lights if necessary. |
| */ |
| public static void dimClockView(boolean dim, View clockView) { |
| Paint paint = new Paint(); |
| paint.setColor(Color.WHITE); |
| paint.setColorFilter(new PorterDuffColorFilter( |
| (dim ? 0x40FFFFFF : 0xC0FFFFFF), |
| PorterDuff.Mode.MULTIPLY)); |
| clockView.setLayerType(View.LAYER_TYPE_HARDWARE, paint); |
| } |
| |
| /** |
| * Update and return the PendingIntent corresponding to the given {@code intent}. |
| * |
| * @param context the Context in which the PendingIntent should start the service |
| * @param intent an Intent describing the service to be started |
| * @return a PendingIntent that will start a service |
| */ |
| public static PendingIntent pendingServiceIntent(Context context, Intent intent) { |
| return PendingIntent.getService(context, 0, intent, FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE); |
| } |
| |
| /** |
| * Update and return the PendingIntent corresponding to the given {@code intent}. |
| * |
| * @param context the Context in which the PendingIntent should start the activity |
| * @param intent an Intent describing the activity to be started |
| * @return a PendingIntent that will start an activity |
| */ |
| public static PendingIntent pendingActivityIntent(Context context, Intent intent) { |
| // explicitly set the flag here, as getActivity() documentation states we must do so |
| return PendingIntent.getActivity(context, 0, intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), |
| FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE); |
| } |
| |
| /** |
| * @return The next alarm from {@link AlarmManager} |
| */ |
| public static String getNextAlarm(Context context) { |
| final AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); |
| final AlarmClockInfo info = getNextAlarmClock(am); |
| if (info != null) { |
| final long triggerTime = info.getTriggerTime(); |
| final Calendar alarmTime = Calendar.getInstance(); |
| alarmTime.setTimeInMillis(triggerTime); |
| return AlarmUtils.getFormattedTime(context, alarmTime); |
| } |
| |
| return null; |
| } |
| |
| private static AlarmClockInfo getNextAlarmClock(AlarmManager am) { |
| return am.getNextAlarmClock(); |
| } |
| |
| public static void updateNextAlarm(AlarmManager am, AlarmClockInfo info, PendingIntent op) { |
| am.setAlarmClock(info, op); |
| } |
| |
| public static boolean isAlarmWithin24Hours(AlarmInstance alarmInstance) { |
| final Calendar nextAlarmTime = alarmInstance.getAlarmTime(); |
| final long nextAlarmTimeMillis = nextAlarmTime.getTimeInMillis(); |
| return nextAlarmTimeMillis - System.currentTimeMillis() <= DateUtils.DAY_IN_MILLIS; |
| } |
| |
| /** |
| * Clock views can call this to refresh their alarm to the next upcoming value. |
| */ |
| public static void refreshAlarm(Context context, View clock) { |
| final TextView nextAlarmIconView = clock.findViewById(R.id.nextAlarmIcon); |
| final TextView nextAlarmView = clock.findViewById(R.id.nextAlarm); |
| if (nextAlarmView == null) { |
| return; |
| } |
| |
| final String alarm = getNextAlarm(context); |
| if (!TextUtils.isEmpty(alarm)) { |
| final String description = context.getString(R.string.next_alarm_description, alarm); |
| nextAlarmView.setText(alarm); |
| nextAlarmView.setContentDescription(description); |
| nextAlarmView.setVisibility(View.VISIBLE); |
| nextAlarmIconView.setVisibility(View.VISIBLE); |
| nextAlarmIconView.setContentDescription(description); |
| } else { |
| nextAlarmView.setVisibility(View.GONE); |
| nextAlarmIconView.setVisibility(View.GONE); |
| } |
| } |
| |
| public static void setClockIconTypeface(View clock) { |
| final TextView nextAlarmIconView = clock.findViewById(R.id.nextAlarmIcon); |
| nextAlarmIconView.setTypeface(UiDataModel.getUiDataModel().getAlarmIconTypeface()); |
| } |
| |
| /** |
| * Clock views can call this to refresh their date. |
| **/ |
| public static void updateDate(String dateSkeleton, String descriptionSkeleton, View clock) { |
| final TextView dateDisplay = clock.findViewById(R.id.date); |
| if (dateDisplay == null) { |
| return; |
| } |
| |
| final Locale l = Locale.getDefault(); |
| final String datePattern = DateFormat.getBestDateTimePattern(l, dateSkeleton); |
| final String descriptionPattern = DateFormat.getBestDateTimePattern(l, descriptionSkeleton); |
| |
| final Date now = new Date(); |
| dateDisplay.setText(new SimpleDateFormat(datePattern, l).format(now)); |
| dateDisplay.setVisibility(View.VISIBLE); |
| dateDisplay.setContentDescription(new SimpleDateFormat(descriptionPattern, l).format(now)); |
| } |
| |
| public static void updateDateGravity(View clockFrame) { |
| View dateAndNextAlarm = clockFrame.findViewById(R.id.date_and_next_alarm_time); |
| LinearLayout.LayoutParams lp = |
| (LinearLayout.LayoutParams)dateAndNextAlarm.getLayoutParams(); |
| |
| final DataModel.ClockStyle clockStyle = DataModel.getDataModel().getClockStyle(); |
| switch (clockStyle) { |
| case ANALOG: |
| lp.gravity = Gravity.CENTER; |
| break; |
| case DIGITAL: |
| lp.gravity = Gravity.START; |
| break; |
| } |
| dateAndNextAlarm.setLayoutParams(lp); |
| } |
| |
| /*** |
| * Formats the time in the TextClock according to the Locale with a special |
| * formatting treatment for the am/pm label. |
| * |
| * @param clock TextClock to format |
| * @param includeSeconds whether or not to include seconds in the clock's time |
| */ |
| public static void setTimeFormat(TextClock clock, boolean includeSeconds) { |
| if (clock != null) { |
| // Get the best format for 12 hours mode according to the locale |
| clock.setFormat12Hour(get12ModeFormat(0.4f /* amPmRatio */, includeSeconds)); |
| // Get the best format for 24 hours mode according to the locale |
| clock.setFormat24Hour(get24ModeFormat(includeSeconds)); |
| } |
| } |
| |
| /** |
| * @param amPmRatio a value between 0 and 1 that is the ratio of the relative size of the |
| * am/pm string to the time string |
| * @param includeSeconds whether or not to include seconds in the time string |
| * @return format string for 12 hours mode time, not including seconds |
| */ |
| public static CharSequence get12ModeFormat(float amPmRatio, boolean includeSeconds) { |
| String pattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), |
| includeSeconds ? "hmsa" : "hma"); |
| if (amPmRatio <= 0) { |
| pattern = pattern.replaceAll("a", "").trim(); |
| } |
| |
| // Replace spaces with "Hair Space" |
| pattern = pattern.replaceAll(" ", "\u200A"); |
| // Build a spannable so that the am/pm will be formatted |
| int amPmPos = pattern.indexOf('a'); |
| if (amPmPos == -1) { |
| return pattern; |
| } |
| |
| final Spannable sp = new SpannableString(pattern); |
| sp.setSpan(new RelativeSizeSpan(amPmRatio), amPmPos, amPmPos + 1, |
| Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| sp.setSpan(new StyleSpan(Typeface.BOLD), amPmPos, amPmPos + 1, |
| Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| sp.setSpan(new TypefaceSpan("sans-serif"), amPmPos, amPmPos + 1, |
| Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); |
| |
| return sp; |
| } |
| |
| public static CharSequence get24ModeFormat(boolean includeSeconds) { |
| return DateFormat.getBestDateTimePattern(Locale.getDefault(), |
| includeSeconds ? "Hms" : "Hm"); |
| } |
| |
| /** |
| * Returns string denoting the timezone hour offset (e.g. GMT -8:00) |
| * |
| * @param useShortForm Whether to return a short form of the header that rounds to the |
| * nearest hour and excludes the "GMT" prefix |
| */ |
| public static String getGMTHourOffset(TimeZone timezone, boolean useShortForm) { |
| final int gmtOffset = timezone.getRawOffset(); |
| final long hour = gmtOffset / DateUtils.HOUR_IN_MILLIS; |
| final long min = (Math.abs(gmtOffset) % DateUtils.HOUR_IN_MILLIS) / |
| DateUtils.MINUTE_IN_MILLIS; |
| |
| if (useShortForm) { |
| return String.format(Locale.ENGLISH, "%+d", hour); |
| } else { |
| return String.format(Locale.ENGLISH, "GMT %+d:%02d", hour, min); |
| } |
| } |
| |
| /** |
| * Given a point in time, return the subsequent moment any of the time zones changes days. |
| * e.g. Given 8:00pm on 1/1/2016 and time zones in LA and NY this method would return a Date for |
| * midnight on 1/2/2016 in the NY timezone since it changes days first. |
| * |
| * @param time a point in time from which to compute midnight on the subsequent day |
| * @param zones a collection of time zones |
| * @return the nearest point in the future at which any of the time zones changes days |
| */ |
| public static Date getNextDay(Date time, Collection<TimeZone> zones) { |
| Calendar next = null; |
| for (TimeZone tz : zones) { |
| final Calendar c = Calendar.getInstance(tz); |
| c.setTime(time); |
| |
| // Advance to the next day. |
| c.add(Calendar.DAY_OF_YEAR, 1); |
| |
| // Reset the time to midnight. |
| c.set(Calendar.HOUR_OF_DAY, 0); |
| c.set(Calendar.MINUTE, 0); |
| c.set(Calendar.SECOND, 0); |
| c.set(Calendar.MILLISECOND, 0); |
| |
| if (next == null || c.compareTo(next) < 0) { |
| next = c; |
| } |
| } |
| |
| return next == null ? null : next.getTime(); |
| } |
| |
| public static String getNumberFormattedQuantityString(Context context, int id, int quantity) { |
| final String localizedQuantity = NumberFormat.getInstance().format(quantity); |
| return context.getResources().getQuantityString(id, quantity, localizedQuantity); |
| } |
| |
| /** |
| * @return {@code true} iff the widget is being hosted in a container where tapping is allowed |
| */ |
| public static boolean isWidgetClickable(AppWidgetManager widgetManager, int widgetId) { |
| final Bundle wo = widgetManager.getAppWidgetOptions(widgetId); |
| return wo != null |
| && wo.getInt(OPTION_APPWIDGET_HOST_CATEGORY, -1) != WIDGET_CATEGORY_KEYGUARD; |
| } |
| |
| /** |
| * @return a vector-drawable inflated from the given {@code resId} |
| */ |
| public static VectorDrawableCompat getVectorDrawable(Context context, @DrawableRes int resId) { |
| return VectorDrawableCompat.create(context.getResources(), resId, context.getTheme()); |
| } |
| |
| /** |
| * {@link ArraySet} is @hide prior to {@link Build.VERSION_CODES#M}. |
| */ |
| @SuppressLint("NewApi") |
| public static <E> ArraySet<E> newArraySet(Collection<E> collection) { |
| final ArraySet<E> arraySet = new ArraySet<>(collection.size()); |
| arraySet.addAll(collection); |
| return arraySet; |
| } |
| |
| /** |
| * @param context from which to query the current device configuration |
| * @return {@code true} if the device is currently in portrait or reverse portrait orientation |
| */ |
| public static boolean isPortrait(Context context) { |
| return context.getResources().getConfiguration().orientation == ORIENTATION_PORTRAIT; |
| } |
| |
| /** |
| * @param context from which to query the current device configuration |
| * @return {@code true} if the device is currently in landscape or reverse landscape orientation |
| */ |
| public static boolean isLandscape(Context context) { |
| return context.getResources().getConfiguration().orientation == ORIENTATION_LANDSCAPE; |
| } |
| |
| public static long now() { |
| return DataModel.getDataModel().elapsedRealtime(); |
| } |
| |
| public static long wallClock() { |
| return DataModel.getDataModel().currentTimeMillis(); |
| } |
| |
| /** |
| * @param context to obtain strings. |
| * @param displayMinutes whether or not minutes should be included |
| * @param isAhead {@code true} if the time should be marked 'ahead', else 'behind' |
| * @param hoursDifferent the number of hours the time is ahead/behind |
| * @param minutesDifferent the number of minutes the time is ahead/behind |
| * @return String describing the hours/minutes ahead or behind |
| */ |
| public static String createHoursDifferentString(Context context, boolean displayMinutes, |
| boolean isAhead, int hoursDifferent, int minutesDifferent) { |
| String timeString; |
| if (displayMinutes && hoursDifferent != 0) { |
| // Both minutes and hours |
| final String hoursShortQuantityString = |
| Utils.getNumberFormattedQuantityString(context, |
| R.plurals.hours_short, Math.abs(hoursDifferent)); |
| final String minsShortQuantityString = |
| Utils.getNumberFormattedQuantityString(context, |
| R.plurals.minutes_short, Math.abs(minutesDifferent)); |
| final @StringRes int stringType = isAhead |
| ? R.string.world_hours_minutes_ahead |
| : R.string.world_hours_minutes_behind; |
| timeString = context.getString(stringType, hoursShortQuantityString, |
| minsShortQuantityString); |
| } else { |
| // Minutes alone or hours alone |
| final String hoursQuantityString = Utils.getNumberFormattedQuantityString( |
| context, R.plurals.hours, Math.abs(hoursDifferent)); |
| final String minutesQuantityString = Utils.getNumberFormattedQuantityString( |
| context, R.plurals.minutes, Math.abs(minutesDifferent)); |
| final @StringRes int stringType = isAhead ? R.string.world_time_ahead |
| : R.string.world_time_behind; |
| timeString = context.getString(stringType, displayMinutes |
| ? minutesQuantityString : hoursQuantityString); |
| } |
| return timeString; |
| } |
| |
| /** |
| * @param context The context from which to obtain strings |
| * @param hours Hours to display (if any) |
| * @param minutes Minutes to display (if any) |
| * @param seconds Seconds to display |
| * @return Provided time formatted as a String |
| */ |
| static String getTimeString(Context context, int hours, int minutes, int seconds) { |
| if (hours != 0) { |
| return context.getString(R.string.hours_minutes_seconds, hours, minutes, seconds); |
| } |
| if (minutes != 0) { |
| return context.getString(R.string.minutes_seconds, minutes, seconds); |
| } |
| return context.getString(R.string.seconds, seconds); |
| } |
| |
| public static final class ClickAccessibilityDelegate extends AccessibilityDelegateCompat { |
| |
| /** The label for talkback to apply to the view */ |
| private final String mLabel; |
| |
| /** Whether or not to always make the view visible to talkback */ |
| private final boolean mIsAlwaysAccessibilityVisible; |
| |
| @SuppressWarnings("unused") |
| public ClickAccessibilityDelegate(String label) { |
| this(label, false); |
| } |
| |
| @SuppressWarnings("unused") |
| public ClickAccessibilityDelegate(String label, boolean isAlwaysAccessibilityVisible) { |
| mLabel = label; |
| mIsAlwaysAccessibilityVisible = isAlwaysAccessibilityVisible; |
| } |
| |
| @Override |
| public void onInitializeAccessibilityNodeInfo(@NonNull View host, |
| @NonNull AccessibilityNodeInfoCompat info) { |
| super.onInitializeAccessibilityNodeInfo(host, info); |
| if (mIsAlwaysAccessibilityVisible) { |
| info.setVisibleToUser(true); |
| } |
| info.addAction(new AccessibilityActionCompat( |
| AccessibilityActionCompat.ACTION_CLICK.getId(), mLabel)); |
| } |
| } |
| } |