1/*
2 * Copyright (C) 2010 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
17package com.android.calendar;
18
19import com.android.calendar.TimezoneAdapter.TimezoneRow;
20
21import android.content.Context;
22import android.content.SharedPreferences;
23import android.content.res.Resources;
24import android.text.TextUtils;
25import android.text.format.DateUtils;
26import android.util.Log;
27import android.widget.ArrayAdapter;
28
29import java.util.ArrayList;
30import java.util.Arrays;
31import java.util.Collections;
32import java.util.LinkedHashMap;
33import java.util.LinkedHashSet;
34import java.util.List;
35import java.util.TimeZone;
36
37/**
38 * {@link TimezoneAdapter} is a custom adapter implementation that allows you to
39 * easily display a list of timezones for users to choose from. In addition, it
40 * provides a two-stage behavior that initially only loads a small set of
41 * timezones (one user-provided, the device timezone, and two recent timezones),
42 * which can later be expanded into the full list with a call to
43 * {@link #showAllTimezones()}.
44 */
45public class TimezoneAdapter extends ArrayAdapter<TimezoneRow> {
46    private static final String TAG = "TimezoneAdapter";
47    private static final boolean DEBUG = true;
48
49    /**
50     * {@link TimezoneRow} is an immutable class for representing a timezone. We
51     * don't use {@link TimeZone} directly, in order to provide a reasonable
52     * implementation of toString() and to control which display names we use.
53     */
54    public static class TimezoneRow implements Comparable<TimezoneRow> {
55
56        /** The ID of this timezone, e.g. "America/Los_Angeles" */
57        public final String mId;
58
59        /** The display name of this timezone, e.g. "Pacific Time" */
60        public final String mDisplayName;
61
62        /** The actual offset of this timezone from GMT in milliseconds */
63        public final int mOffset;
64
65        /**
66         * A one-line representation of this timezone, including both GMT offset
67         * and display name, e.g. "(GMT-7:00) Pacific Time"
68         */
69        private final String mGmtDisplayName;
70
71        public TimezoneRow(String id, String displayName) {
72            mId = id;
73            mDisplayName = displayName;
74            TimeZone tz = TimeZone.getTimeZone(id);
75
76            int offset = tz.getOffset(System.currentTimeMillis());
77            mOffset = offset;
78            int p = Math.abs(offset);
79            StringBuilder name = new StringBuilder();
80            name.append("GMT");
81
82            if (offset < 0) {
83                name.append('-');
84            } else {
85                name.append('+');
86            }
87
88            name.append(p / (DateUtils.HOUR_IN_MILLIS));
89            name.append(':');
90
91            int min = p / 60000;
92            min %= 60;
93
94            if (min < 10) {
95                name.append('0');
96            }
97            name.append(min);
98            name.insert(0, "(");
99            name.append(") ");
100            name.append(displayName);
101            mGmtDisplayName = name.toString();
102        }
103
104        @Override
105        public String toString() {
106            return mGmtDisplayName;
107        }
108
109        @Override
110        public int hashCode() {
111            final int prime = 31;
112            int result = 1;
113            result = prime * result + ((mDisplayName == null) ? 0 : mDisplayName.hashCode());
114            result = prime * result + ((mId == null) ? 0 : mId.hashCode());
115            result = prime * result + mOffset;
116            return result;
117        }
118
119        @Override
120        public boolean equals(Object obj) {
121            if (this == obj) {
122                return true;
123            }
124            if (obj == null) {
125                return false;
126            }
127            if (getClass() != obj.getClass()) {
128                return false;
129            }
130            TimezoneRow other = (TimezoneRow) obj;
131            if (mDisplayName == null) {
132                if (other.mDisplayName != null) {
133                    return false;
134                }
135            } else if (!mDisplayName.equals(other.mDisplayName)) {
136                return false;
137            }
138            if (mId == null) {
139                if (other.mId != null) {
140                    return false;
141                }
142            } else if (!mId.equals(other.mId)) {
143                return false;
144            }
145            if (mOffset != other.mOffset) {
146                return false;
147            }
148            return true;
149        }
150
151        @Override
152        public int compareTo(TimezoneRow another) {
153            if (mOffset == another.mOffset) {
154                return 0;
155            } else {
156                return mOffset < another.mOffset ? -1 : 1;
157            }
158        }
159
160    }
161
162    private static final String KEY_RECENT_TIMEZONES = "preferences_recent_timezones";
163
164    /** The delimiter we use when serializing recent timezones to shared preferences */
165    private static final String RECENT_TIMEZONES_DELIMITER = ",";
166
167    /** The maximum number of recent timezones to save */
168    private static final int MAX_RECENT_TIMEZONES = 3;
169
170    /**
171     * Static cache of all known timezones, mapped to their string IDs. This is
172     * lazily-loaded on the first call to {@link #loadFromResources(Resources)}.
173     * Loading is called in a synchronized block during initialization of this
174     * class and is based off the resources available to the calling context.
175     * This class should not be used outside of the initial context.
176     * LinkedHashMap is used to preserve ordering.
177     */
178    private static LinkedHashMap<String, TimezoneRow> sTimezones;
179
180    private Context mContext;
181
182    private String mCurrentTimezone;
183
184    private boolean mShowingAll = false;
185
186    /**
187     * Constructs a timezone adapter that contains an initial set of entries
188     * including the current timezone, the device timezone, and two recently
189     * used timezones.
190     *
191     * @param context
192     * @param currentTimezone
193     */
194    public TimezoneAdapter(Context context, String currentTimezone) {
195        super(context, android.R.layout.simple_spinner_dropdown_item, android.R.id.text1);
196        mContext = context;
197        mCurrentTimezone = currentTimezone;
198        mShowingAll = false;
199        showInitialTimezones();
200    }
201
202    /**
203     * Given the ID of a timezone, returns the position of the timezone in this
204     * adapter, or -1 if not found.
205     *
206     * @param id the ID of the timezone to find
207     * @return the row position of the timezone, or -1 if not found
208     */
209    public int getRowById(String id) {
210        TimezoneRow timezone = sTimezones.get(id);
211        if (timezone == null) {
212            return -1;
213        } else {
214            return getPosition(timezone);
215        }
216    }
217
218    /**
219     * Populates the adapter with an initial list of timezones (one
220     * user-provided, the device timezone, and two recent timezones), which can
221     * later be expanded into the full list with a call to
222     * {@link #showAllTimezones()}.
223     *
224     * @param currentTimezone
225     */
226    public void showInitialTimezones() {
227
228        // we use a linked hash set to guarantee only unique IDs are added, and
229        // also to maintain the insertion order of the timezones
230        LinkedHashSet<String> ids = new LinkedHashSet<String>();
231
232        // add in the provided (event) timezone
233        if (!TextUtils.isEmpty(mCurrentTimezone)) {
234            ids.add(mCurrentTimezone);
235        }
236
237        // add in the device timezone if it is different
238        ids.add(TimeZone.getDefault().getID());
239
240        // add in recent timezone selections
241        SharedPreferences prefs = GeneralPreferences.getSharedPreferences(mContext);
242        String recentsString = prefs.getString(KEY_RECENT_TIMEZONES, null);
243        if (recentsString != null) {
244            String[] recents = recentsString.split(RECENT_TIMEZONES_DELIMITER);
245            for (String recent : recents) {
246                if (!TextUtils.isEmpty(recent)) {
247                    ids.add(recent);
248                }
249            }
250        }
251
252        clear();
253
254        synchronized (TimezoneAdapter.class) {
255            loadFromResources(mContext.getResources());
256            TimeZone gmt = TimeZone.getTimeZone("GMT");
257            for (String id : ids) {
258                if (!sTimezones.containsKey(id)) {
259                    // a timezone we don't know about, so try to add it...
260                    TimeZone newTz = TimeZone.getTimeZone(id);
261                    // since TimeZone.getTimeZone actually returns a clone of GMT
262                    // when it doesn't recognize the ID, this appears to be the only
263                    // reliable way to check to see if the ID is a valid timezone
264                    if (!newTz.equals(gmt)) {
265                        sTimezones.put(id, new TimezoneRow(id, newTz.getDisplayName()));
266                    } else {
267                        continue;
268                    }
269                }
270                add(sTimezones.get(id));
271            }
272        }
273        mShowingAll = false;
274    }
275
276    /**
277     * Populates this adapter with all known timezones.
278     */
279    public void showAllTimezones() {
280        List<TimezoneRow> timezones = new ArrayList<TimezoneRow>(sTimezones.values());
281        Collections.sort(timezones);
282        clear();
283        for (TimezoneRow timezone : timezones) {
284            add(timezone);
285        }
286        mShowingAll = true;
287    }
288
289    /**
290     * Sets the current timezone. If the adapter is currently displaying only a
291     * subset of views, reload that view since it may have changed.
292     *
293     * @param currentTimezone the current timezone
294     */
295    public void setCurrentTimezone(String currentTimezone) {
296        mCurrentTimezone = currentTimezone;
297        if (!mShowingAll) {
298            showInitialTimezones();
299        }
300    }
301
302    /**
303     * Saves the given timezone ID as a recent timezone under shared
304     * preferences. If there are already the maximum number of recent timezones
305     * saved, it will remove the oldest and append this one.
306     *
307     * @param id the ID of the timezone to save
308     * @see {@link #MAX_RECENT_TIMEZONES}
309     */
310    public void saveRecentTimezone(String id) {
311        SharedPreferences prefs = GeneralPreferences.getSharedPreferences(mContext);
312        String recentsString = prefs.getString(KEY_RECENT_TIMEZONES, null);
313        List<String> recents;
314        if (recentsString == null) {
315            recents = new ArrayList<String>(MAX_RECENT_TIMEZONES);
316        } else {
317            recents = new ArrayList<String>(
318                Arrays.asList(recentsString.split(RECENT_TIMEZONES_DELIMITER)));
319        }
320
321        while (recents.size() >= MAX_RECENT_TIMEZONES) {
322            recents.remove(0);
323        }
324        recents.add(id);
325        recentsString = Utils.join(recents, RECENT_TIMEZONES_DELIMITER);
326        Utils.setSharedPreference(mContext, KEY_RECENT_TIMEZONES, recentsString);
327    }
328
329    /**
330     * Returns an array of ids/time zones. This returns a double indexed array
331     * of ids and time zones for Calendar. It is an inefficient method and
332     * shouldn't be called often, but can be used for one time generation of
333     * this list.
334     *
335     * @return double array of tz ids and tz names
336     */
337    public CharSequence[][] getAllTimezones() {
338        CharSequence[][] timeZones = new CharSequence[2][sTimezones.size()];
339        List<String> ids = new ArrayList<String>(sTimezones.keySet());
340        List<TimezoneRow> timezones = new ArrayList<TimezoneRow>(sTimezones.values());
341        int i = 0;
342        for (TimezoneRow row : timezones) {
343            timeZones[0][i] = ids.get(i);
344            timeZones[1][i++] = row.toString();
345        }
346        return timeZones;
347    }
348
349    private void loadFromResources(Resources resources) {
350        if (sTimezones == null) {
351            String[] ids = resources.getStringArray(R.array.timezone_values);
352            String[] labels = resources.getStringArray(R.array.timezone_labels);
353
354            int length = ids.length;
355            sTimezones = new LinkedHashMap<String, TimezoneRow>(length);
356
357            if (ids.length != labels.length) {
358                Log.wtf(TAG, "ids length (" + ids.length + ") and labels length(" + labels.length +
359                        ") should be equal but aren't.");
360            }
361            for (int i = 0; i < length; i++) {
362                sTimezones.put(ids[i], new TimezoneRow(ids[i], labels[i]));
363            }
364        }
365    }
366}
367