1 /*
2  * Copyright (C) 2012 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of 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,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.deskclock;
18 
19 import android.app.Activity;
20 import android.app.AlarmManager;
21 import android.content.BroadcastReceiver;
22 import android.content.Context;
23 import android.content.Intent;
24 import android.content.IntentFilter;
25 import android.content.res.Resources;
26 import android.database.ContentObserver;
27 import android.net.Uri;
28 import android.os.Bundle;
29 import android.os.Handler;
30 import android.provider.Settings;
31 import androidx.annotation.NonNull;
32 import androidx.recyclerview.widget.LinearLayoutManager;
33 import androidx.recyclerview.widget.RecyclerView;
34 import android.text.format.DateUtils;
35 import android.view.GestureDetector;
36 import android.view.LayoutInflater;
37 import android.view.MotionEvent;
38 import android.view.View;
39 import android.view.ViewGroup;
40 import android.widget.Button;
41 import android.widget.ImageView;
42 import android.widget.TextClock;
43 import android.widget.TextView;
44 
45 import com.android.deskclock.data.City;
46 import com.android.deskclock.data.CityListener;
47 import com.android.deskclock.data.DataModel;
48 import com.android.deskclock.events.Events;
49 import com.android.deskclock.uidata.UiDataModel;
50 import com.android.deskclock.worldclock.CitySelectionActivity;
51 
52 import java.util.Calendar;
53 import java.util.List;
54 import java.util.TimeZone;
55 
56 import static android.app.AlarmManager.ACTION_NEXT_ALARM_CLOCK_CHANGED;
57 import static android.view.View.GONE;
58 import static android.view.View.INVISIBLE;
59 import static android.view.View.VISIBLE;
60 import static com.android.deskclock.uidata.UiDataModel.Tab.CLOCKS;
61 import static java.util.Calendar.DAY_OF_WEEK;
62 
63 /**
64  * Fragment that shows the clock (analog or digital), the next alarm info and the world clock.
65  */
66 public final class ClockFragment extends DeskClockFragment {
67 
68     // Updates dates in the UI on every quarter-hour.
69     private final Runnable mQuarterHourUpdater = new QuarterHourRunnable();
70 
71     // Updates the UI in response to changes to the scheduled alarm.
72     private BroadcastReceiver mAlarmChangeReceiver;
73 
74     // Detects changes to the next scheduled alarm pre-L.
75     private ContentObserver mAlarmObserver;
76 
77     private TextClock mDigitalClock;
78     private AnalogClock mAnalogClock;
79     private View mClockFrame;
80     private SelectedCitiesAdapter mCityAdapter;
81     private RecyclerView mCityList;
82     private String mDateFormat;
83     private String mDateFormatForAccessibility;
84 
85     /**
86      * The public no-arg constructor required by all fragments.
87      */
ClockFragment()88     public ClockFragment() {
89         super(CLOCKS);
90     }
91 
92     @Override
onCreate(Bundle savedInstanceState)93     public void onCreate(Bundle savedInstanceState) {
94         super.onCreate(savedInstanceState);
95 
96         mAlarmObserver = Utils.isPreL() ? new AlarmObserverPreL() : null;
97         mAlarmChangeReceiver = Utils.isLOrLater() ? new AlarmChangedBroadcastReceiver() : null;
98     }
99 
100     @Override
onCreateView(LayoutInflater inflater, ViewGroup container, Bundle icicle)101     public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle icicle) {
102         super.onCreateView(inflater, container, icicle);
103 
104         final View fragmentView = inflater.inflate(R.layout.clock_fragment, container, false);
105 
106         mDateFormat = getString(R.string.abbrev_wday_month_day_no_year);
107         mDateFormatForAccessibility = getString(R.string.full_wday_month_day_no_year);
108 
109         mCityAdapter = new SelectedCitiesAdapter(getActivity(), mDateFormat,
110                 mDateFormatForAccessibility);
111 
112         mCityList = (RecyclerView) fragmentView.findViewById(R.id.cities);
113         mCityList.setLayoutManager(new LinearLayoutManager(getActivity()));
114         mCityList.setAdapter(mCityAdapter);
115         mCityList.setItemAnimator(null);
116         DataModel.getDataModel().addCityListener(mCityAdapter);
117 
118         final ScrollPositionWatcher scrollPositionWatcher = new ScrollPositionWatcher();
119         mCityList.addOnScrollListener(scrollPositionWatcher);
120 
121         final Context context = container.getContext();
122         mCityList.setOnTouchListener(new CityListOnLongClickListener(context));
123         fragmentView.setOnLongClickListener(new StartScreenSaverListener());
124 
125         // On tablet landscape, the clock frame will be a distinct view. Otherwise, it'll be added
126         // on as a header to the main listview.
127         mClockFrame = fragmentView.findViewById(R.id.main_clock_left_pane);
128         if (mClockFrame != null) {
129             mDigitalClock = (TextClock) mClockFrame.findViewById(R.id.digital_clock);
130             mAnalogClock = (AnalogClock) mClockFrame.findViewById(R.id.analog_clock);
131             Utils.setClockIconTypeface(mClockFrame);
132             Utils.updateDate(mDateFormat, mDateFormatForAccessibility, mClockFrame);
133             Utils.setClockStyle(mDigitalClock, mAnalogClock);
134             Utils.setClockSecondsEnabled(mDigitalClock, mAnalogClock);
135         }
136 
137         // Schedule a runnable to update the date every quarter hour.
138         UiDataModel.getUiDataModel().addQuarterHourCallback(mQuarterHourUpdater, 100);
139 
140         return fragmentView;
141     }
142 
143     @Override
onResume()144     public void onResume() {
145         super.onResume();
146 
147         final Activity activity = getActivity();
148 
149         mDateFormat = getString(R.string.abbrev_wday_month_day_no_year);
150         mDateFormatForAccessibility = getString(R.string.full_wday_month_day_no_year);
151 
152         // Watch for system events that effect clock time or format.
153         if (mAlarmChangeReceiver != null) {
154             final IntentFilter filter = new IntentFilter(ACTION_NEXT_ALARM_CLOCK_CHANGED);
155             activity.registerReceiver(mAlarmChangeReceiver, filter);
156         }
157 
158         // Resume can be invoked after changing the clock style or seconds display.
159         if (mDigitalClock != null && mAnalogClock != null) {
160             Utils.setClockStyle(mDigitalClock, mAnalogClock);
161             Utils.setClockSecondsEnabled(mDigitalClock, mAnalogClock);
162         }
163 
164         final View view = getView();
165         if (view != null && view.findViewById(R.id.main_clock_left_pane) != null) {
166             // Center the main clock frame by hiding the world clocks when none are selected.
167             mCityList.setVisibility(mCityAdapter.getItemCount() == 0 ? GONE : VISIBLE);
168         }
169 
170         refreshAlarm();
171 
172         // Alarm observer is null on L or later.
173         if (mAlarmObserver != null) {
174             @SuppressWarnings("deprecation")
175             final Uri uri = Settings.System.getUriFor(Settings.System.NEXT_ALARM_FORMATTED);
176             activity.getContentResolver().registerContentObserver(uri, false, mAlarmObserver);
177         }
178     }
179 
180     @Override
onPause()181     public void onPause() {
182         super.onPause();
183 
184         final Activity activity = getActivity();
185         if (mAlarmChangeReceiver != null) {
186             activity.unregisterReceiver(mAlarmChangeReceiver);
187         }
188         if (mAlarmObserver != null) {
189             activity.getContentResolver().unregisterContentObserver(mAlarmObserver);
190         }
191     }
192 
193     @Override
onDestroyView()194     public void onDestroyView() {
195         super.onDestroyView();
196         UiDataModel.getUiDataModel().removePeriodicCallback(mQuarterHourUpdater);
197         DataModel.getDataModel().removeCityListener(mCityAdapter);
198     }
199 
200     @Override
onFabClick(@onNull ImageView fab)201     public void onFabClick(@NonNull ImageView fab) {
202         startActivity(new Intent(getActivity(), CitySelectionActivity.class));
203     }
204 
205     @Override
onUpdateFab(@onNull ImageView fab)206     public void onUpdateFab(@NonNull ImageView fab) {
207         fab.setVisibility(VISIBLE);
208         fab.setImageResource(R.drawable.ic_public);
209         fab.setContentDescription(fab.getResources().getString(R.string.button_cities));
210     }
211 
212     @Override
onUpdateFabButtons(@onNull Button left, @NonNull Button right)213     public void onUpdateFabButtons(@NonNull Button left, @NonNull Button right) {
214         left.setVisibility(INVISIBLE);
215         right.setVisibility(INVISIBLE);
216     }
217 
218     /**
219      * Refresh the next alarm time.
220      */
refreshAlarm()221     private void refreshAlarm() {
222         if (mClockFrame != null) {
223             Utils.refreshAlarm(getActivity(), mClockFrame);
224         } else {
225             mCityAdapter.refreshAlarm();
226         }
227     }
228 
229     /**
230      * Long pressing over the main clock starts the screen saver.
231      */
232     private final class StartScreenSaverListener implements View.OnLongClickListener {
233 
234         @Override
onLongClick(View view)235         public boolean onLongClick(View view) {
236             startActivity(new Intent(getActivity(), ScreensaverActivity.class)
237                     .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
238                     .putExtra(Events.EXTRA_EVENT_LABEL, R.string.label_deskclock));
239             return true;
240         }
241     }
242 
243     /**
244      * Long pressing over the city list starts the screen saver.
245      */
246     private final class CityListOnLongClickListener extends GestureDetector.SimpleOnGestureListener
247             implements View.OnTouchListener {
248 
249         private final GestureDetector mGestureDetector;
250 
CityListOnLongClickListener(Context context)251         private CityListOnLongClickListener(Context context) {
252             mGestureDetector = new GestureDetector(context, this);
253         }
254 
255         @Override
onLongPress(MotionEvent e)256         public void onLongPress(MotionEvent e) {
257             final View view = getView();
258             if (view != null) {
259                 view.performLongClick();
260             }
261         }
262 
263         @Override
onDown(MotionEvent e)264         public boolean onDown(MotionEvent e) {
265             return true;
266         }
267 
268         @Override
onTouch(View v, MotionEvent event)269         public boolean onTouch(View v, MotionEvent event) {
270             return mGestureDetector.onTouchEvent(event);
271         }
272     }
273 
274     /**
275      * This runnable executes at every quarter-hour (e.g. 1:00, 1:15, 1:30, 1:45, etc...) and
276      * updates the dates displayed within the UI. Quarter-hour increments were chosen to accommodate
277      * the "weirdest" timezones (e.g. Nepal is UTC/GMT +05:45).
278      */
279     private final class QuarterHourRunnable implements Runnable {
280         @Override
run()281         public void run() {
282             mCityAdapter.notifyDataSetChanged();
283         }
284     }
285 
286     /**
287      * Prior to L, a ContentObserver was used to monitor changes to the next scheduled alarm.
288      * In L and beyond this is accomplished via a system broadcast of
289      * {@link AlarmManager#ACTION_NEXT_ALARM_CLOCK_CHANGED}.
290      */
291     private final class AlarmObserverPreL extends ContentObserver {
AlarmObserverPreL()292         private AlarmObserverPreL() {
293             super(new Handler());
294         }
295 
296         @Override
onChange(boolean selfChange)297         public void onChange(boolean selfChange) {
298             refreshAlarm();
299         }
300     }
301 
302     /**
303      * Update the display of the scheduled alarm as it changes.
304      */
305     private final class AlarmChangedBroadcastReceiver extends BroadcastReceiver {
306         @Override
onReceive(Context context, Intent intent)307         public void onReceive(Context context, Intent intent) {
308             refreshAlarm();
309         }
310     }
311 
312     /**
313      * Updates the vertical scroll state of this tab in the {@link UiDataModel} as the user scrolls
314      * the recyclerview or when the size/position of elements within the recyclerview changes.
315      */
316     private final class ScrollPositionWatcher extends RecyclerView.OnScrollListener
317             implements View.OnLayoutChangeListener {
318         @Override
onScrolled(RecyclerView recyclerView, int dx, int dy)319         public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
320             setTabScrolledToTop(Utils.isScrolledToTop(mCityList));
321         }
322 
323         @Override
onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom)324         public void onLayoutChange(View v, int left, int top, int right, int bottom,
325                 int oldLeft, int oldTop, int oldRight, int oldBottom) {
326             setTabScrolledToTop(Utils.isScrolledToTop(mCityList));
327         }
328     }
329 
330     /**
331      * This adapter lists all of the selected world clocks. Optionally, it also includes a clock at
332      * the top for the home timezone if "Automatic home clock" is turned on in settings and the
333      * current time at home does not match the current time in the timezone of the current location.
334      * If the phone is in portrait mode it will also include the main clock at the top.
335      */
336     private static final class SelectedCitiesAdapter extends RecyclerView.Adapter
337             implements CityListener {
338 
339         private final static int MAIN_CLOCK = R.layout.main_clock_frame;
340         private final static int WORLD_CLOCK = R.layout.world_clock_item;
341 
342         private final LayoutInflater mInflater;
343         private final Context mContext;
344         private final boolean mIsPortrait;
345         private final boolean mShowHomeClock;
346         private final String mDateFormat;
347         private final String mDateFormatForAccessibility;
348 
SelectedCitiesAdapter(Context context, String dateFormat, String dateFormatForAccessibility)349         private SelectedCitiesAdapter(Context context, String dateFormat,
350                 String dateFormatForAccessibility) {
351             mContext = context;
352             mDateFormat = dateFormat;
353             mDateFormatForAccessibility = dateFormatForAccessibility;
354             mInflater = LayoutInflater.from(context);
355             mIsPortrait = Utils.isPortrait(context);
356             mShowHomeClock = DataModel.getDataModel().getShowHomeClock();
357         }
358 
359         @Override
getItemViewType(int position)360         public int getItemViewType(int position) {
361             if (position == 0 && mIsPortrait) {
362                 return MAIN_CLOCK;
363             }
364             return WORLD_CLOCK;
365         }
366 
367         @Override
onCreateViewHolder(ViewGroup parent, int viewType)368         public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
369             final View view = mInflater.inflate(viewType, parent, false);
370             switch (viewType) {
371                 case WORLD_CLOCK:
372                     return new CityViewHolder(view);
373                 case MAIN_CLOCK:
374                     return new MainClockViewHolder(view);
375                 default:
376                     throw new IllegalArgumentException("View type not recognized");
377             }
378         }
379 
380         @Override
onBindViewHolder(RecyclerView.ViewHolder holder, int position)381         public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
382             final int viewType = getItemViewType(position);
383             switch (viewType) {
384                 case WORLD_CLOCK:
385                     // Retrieve the city to bind.
386                     final City city;
387                     // If showing home clock, put it at the top
388                     if (mShowHomeClock && position == (mIsPortrait ? 1 : 0)) {
389                         city = getHomeCity();
390                     } else {
391                         final int positionAdjuster = (mIsPortrait ? 1 : 0)
392                                 + (mShowHomeClock ? 1 : 0);
393                         city = getCities().get(position - positionAdjuster);
394                     }
395                     ((CityViewHolder) holder).bind(mContext, city, position, mIsPortrait);
396                     break;
397                 case MAIN_CLOCK:
398                     ((MainClockViewHolder) holder).bind(mContext, mDateFormat,
399                             mDateFormatForAccessibility, getItemCount() > 1);
400                     break;
401                 default:
402                     throw new IllegalArgumentException("Unexpected view type: " + viewType);
403             }
404         }
405 
406         @Override
getItemCount()407         public int getItemCount() {
408             final int mainClockCount = mIsPortrait ? 1 : 0;
409             final int homeClockCount = mShowHomeClock ? 1 : 0;
410             final int worldClockCount = getCities().size();
411             return mainClockCount + homeClockCount + worldClockCount;
412         }
413 
getHomeCity()414         private City getHomeCity() {
415             return DataModel.getDataModel().getHomeCity();
416         }
417 
getCities()418         private List<City> getCities() {
419             return DataModel.getDataModel().getSelectedCities();
420         }
421 
refreshAlarm()422         private void refreshAlarm() {
423             if (mIsPortrait && getItemCount() > 0) {
424                 notifyItemChanged(0);
425             }
426         }
427 
428         @Override
citiesChanged(List<City> oldCities, List<City> newCities)429         public void citiesChanged(List<City> oldCities, List<City> newCities) {
430             notifyDataSetChanged();
431         }
432 
433         private static final class CityViewHolder extends RecyclerView.ViewHolder {
434 
435             private final TextView mName;
436             private final TextClock mDigitalClock;
437             private final AnalogClock mAnalogClock;
438             private final TextView mHoursAhead;
439 
CityViewHolder(View itemView)440             private CityViewHolder(View itemView) {
441                 super(itemView);
442 
443                 mName = (TextView) itemView.findViewById(R.id.city_name);
444                 mDigitalClock = (TextClock) itemView.findViewById(R.id.digital_clock);
445                 mAnalogClock = (AnalogClock) itemView.findViewById(R.id.analog_clock);
446                 mHoursAhead = (TextView) itemView.findViewById(R.id.hours_ahead);
447             }
448 
bind(Context context, City city, int position, boolean isPortrait)449             private void bind(Context context, City city, int position, boolean isPortrait) {
450                 final String cityTimeZoneId = city.getTimeZone().getID();
451 
452                 // Configure the digital clock or analog clock depending on the user preference.
453                 if (DataModel.getDataModel().getClockStyle() == DataModel.ClockStyle.ANALOG) {
454                     mDigitalClock.setVisibility(GONE);
455                     mAnalogClock.setVisibility(VISIBLE);
456                     mAnalogClock.setTimeZone(cityTimeZoneId);
457                     mAnalogClock.enableSeconds(false);
458                 } else {
459                     mAnalogClock.setVisibility(GONE);
460                     mDigitalClock.setVisibility(VISIBLE);
461                     mDigitalClock.setTimeZone(cityTimeZoneId);
462                     mDigitalClock.setFormat12Hour(Utils.get12ModeFormat(0.3f /* amPmRatio */,
463                             false));
464                     mDigitalClock.setFormat24Hour(Utils.get24ModeFormat(false));
465                 }
466 
467                 // Supply top and bottom padding dynamically.
468                 final Resources res = context.getResources();
469                 final int padding = res.getDimensionPixelSize(R.dimen.medium_space_top);
470                 final int top = position == 0 && !isPortrait ? 0 : padding;
471                 final int left = itemView.getPaddingLeft();
472                 final int right = itemView.getPaddingRight();
473                 final int bottom = itemView.getPaddingBottom();
474                 itemView.setPadding(left, top, right, bottom);
475 
476                 // Bind the city name.
477                 mName.setText(city.getName());
478 
479                 // Compute if the city week day matches the weekday of the current timezone.
480                 final Calendar localCal = Calendar.getInstance(TimeZone.getDefault());
481                 final Calendar cityCal = Calendar.getInstance(city.getTimeZone());
482                 final boolean displayDayOfWeek =
483                         localCal.get(DAY_OF_WEEK) != cityCal.get(DAY_OF_WEEK);
484 
485                 // Compare offset from UTC time on today's date (daylight savings time, etc.)
486                 final TimeZone currentTimeZone = TimeZone.getDefault();
487                 final TimeZone cityTimeZone = TimeZone.getTimeZone(cityTimeZoneId);
488                 final long currentTimeMillis = System.currentTimeMillis();
489                 final long currentUtcOffset = currentTimeZone.getOffset(currentTimeMillis);
490                 final long cityUtcOffset = cityTimeZone.getOffset(currentTimeMillis);
491                 final long offsetDelta = cityUtcOffset - currentUtcOffset;
492 
493                 final int hoursDifferent = (int) (offsetDelta / DateUtils.HOUR_IN_MILLIS);
494                 final int minutesDifferent = (int) (offsetDelta / DateUtils.MINUTE_IN_MILLIS) % 60;
495                 final boolean displayMinutes = offsetDelta % DateUtils.HOUR_IN_MILLIS != 0;
496                 final boolean isAhead = hoursDifferent > 0 || (hoursDifferent == 0
497                         && minutesDifferent > 0);
498                 if (!Utils.isLandscape(context)) {
499                     // Bind the number of hours ahead or behind, or hide if the time is the same.
500                     final boolean displayDifference = hoursDifferent != 0 || displayMinutes;
501                     mHoursAhead.setVisibility(displayDifference ? VISIBLE : GONE);
502                     final String timeString = Utils.createHoursDifferentString(
503                             context, displayMinutes, isAhead, hoursDifferent, minutesDifferent);
504                     mHoursAhead.setText(displayDayOfWeek ?
505                             (context.getString(isAhead ? R.string.world_hours_tomorrow
506                                     : R.string.world_hours_yesterday, timeString))
507                             : timeString);
508                 } else {
509                     // Only tomorrow/yesterday should be shown in landscape view.
510                     mHoursAhead.setVisibility(displayDayOfWeek ? View.VISIBLE : View.GONE);
511                     if (displayDayOfWeek) {
512                         mHoursAhead.setText(context.getString(isAhead ? R.string.world_tomorrow
513                                 : R.string.world_yesterday));
514                     }
515 
516                 }
517             }
518         }
519 
520         private static final class MainClockViewHolder extends RecyclerView.ViewHolder {
521 
522             private final View mHairline;
523             private final TextClock mDigitalClock;
524             private final AnalogClock mAnalogClock;
525 
MainClockViewHolder(View itemView)526             private MainClockViewHolder(View itemView) {
527                 super(itemView);
528 
529                 mHairline = itemView.findViewById(R.id.hairline);
530                 mDigitalClock = (TextClock) itemView.findViewById(R.id.digital_clock);
531                 mAnalogClock = (AnalogClock) itemView.findViewById(R.id.analog_clock);
532                 Utils.setClockIconTypeface(itemView);
533             }
534 
bind(Context context, String dateFormat, String dateFormatForAccessibility, boolean showHairline)535             private void bind(Context context, String dateFormat,
536                     String dateFormatForAccessibility, boolean showHairline) {
537                 Utils.refreshAlarm(context, itemView);
538 
539                 Utils.updateDate(dateFormat, dateFormatForAccessibility, itemView);
540                 Utils.setClockStyle(mDigitalClock, mAnalogClock);
541                 mHairline.setVisibility(showHairline ? VISIBLE : GONE);
542 
543                 Utils.setClockSecondsEnabled(mDigitalClock, mAnalogClock);
544             }
545         }
546     }
547 }
548