1/*
2 * Copyright (C) 2013 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.timezonepicker;
18
19import android.content.Context;
20import android.content.res.AssetManager;
21import android.content.res.Resources;
22import android.text.format.DateFormat;
23import android.text.format.DateUtils;
24import android.util.Log;
25import android.util.SparseArray;
26
27import java.io.BufferedReader;
28import java.io.IOException;
29import java.io.InputStream;
30import java.io.InputStreamReader;
31import java.util.ArrayList;
32import java.util.Collections;
33import java.util.Date;
34import java.util.HashMap;
35import java.util.HashSet;
36import java.util.LinkedHashMap;
37import java.util.Locale;
38import java.util.TimeZone;
39
40public class TimeZoneData {
41    private static final String TAG = "TimeZoneData";
42    private static final boolean DEBUG = false;
43    private static final int OFFSET_ARRAY_OFFSET = 20;
44
45    private static final String PALESTINE_COUNTRY_CODE = "PS";
46
47
48    ArrayList<TimeZoneInfo> mTimeZones;
49    LinkedHashMap<String, ArrayList<Integer>> mTimeZonesByCountry;
50    HashSet<String> mTimeZoneNames = new HashSet<String>();
51
52    private long mTimeMillis;
53    private HashMap<String, String> mCountryCodeToNameMap = new HashMap<String, String>();
54
55    public String mDefaultTimeZoneId;
56    public static boolean is24HourFormat;
57    private TimeZoneInfo mDefaultTimeZoneInfo;
58    private String mAlternateDefaultTimeZoneId;
59    private String mDefaultTimeZoneCountry;
60    private HashMap<String, TimeZoneInfo> mTimeZonesById;
61    private boolean[] mHasTimeZonesInHrOffset = new boolean[40];
62    SparseArray<ArrayList<Integer>> mTimeZonesByOffsets;
63    private Context mContext;
64    private String mPalestineDisplayName;
65
66    public TimeZoneData(Context context, String defaultTimeZoneId, long timeMillis) {
67        mContext = context;
68        is24HourFormat = TimeZoneInfo.is24HourFormat = DateFormat.is24HourFormat(context);
69        mDefaultTimeZoneId = mAlternateDefaultTimeZoneId = defaultTimeZoneId;
70        long now = System.currentTimeMillis();
71
72        if (timeMillis == 0) {
73            mTimeMillis = now;
74        } else {
75            mTimeMillis = timeMillis;
76        }
77
78        mPalestineDisplayName = context.getResources().getString(R.string.palestine_display_name);
79
80        loadTzs(context);
81
82        Log.i(TAG, "Time to load time zones (ms): " + (System.currentTimeMillis() - now));
83
84        // now = System.currentTimeMillis();
85        // printTz();
86        // Log.i(TAG, "Time to print time zones (ms): " +
87        // (System.currentTimeMillis() - now));
88    }
89
90    public void setTime(long timeMillis) {
91        mTimeMillis = timeMillis;
92    }
93
94    public TimeZoneInfo get(int position) {
95        return mTimeZones.get(position);
96    }
97
98    public int size() {
99        return mTimeZones.size();
100    }
101
102    public int getDefaultTimeZoneIndex() {
103        return mTimeZones.indexOf(mDefaultTimeZoneInfo);
104    }
105
106    // TODO speed this up
107    public int findIndexByTimeZoneIdSlow(String timeZoneId) {
108        int idx = 0;
109        for (TimeZoneInfo tzi : mTimeZones) {
110            if (timeZoneId.equals(tzi.mTzId)) {
111                return idx;
112            }
113            idx++;
114        }
115        return -1;
116    }
117
118    void loadTzs(Context context) {
119        mTimeZones = new ArrayList<TimeZoneInfo>();
120        HashSet<String> processedTimeZones = loadTzsInZoneTab(context);
121        String[] tzIds = TimeZone.getAvailableIDs();
122
123        if (DEBUG) {
124            Log.e(TAG, "Available time zones: " + tzIds.length);
125        }
126
127        for (String tzId : tzIds) {
128            if (processedTimeZones.contains(tzId)) {
129                continue;
130            }
131
132            /*
133             * Dropping non-GMT tzs without a country code. They are not really
134             * needed and they are dups but missing proper country codes. e.g.
135             * WET CET MST7MDT PST8PDT Asia/Khandyga Asia/Ust-Nera EST
136             */
137            if (!tzId.startsWith("Etc/GMT")) {
138                continue;
139            }
140
141            final TimeZone tz = TimeZone.getTimeZone(tzId);
142            if (tz == null) {
143                Log.e(TAG, "Timezone not found: " + tzId);
144                continue;
145            }
146
147            TimeZoneInfo tzInfo = new TimeZoneInfo(tz, null);
148
149            if (getIdenticalTimeZoneInTheCountry(tzInfo) == -1) {
150                if (DEBUG) {
151                    Log.e(TAG, "# Adding time zone from getAvailId: " + tzInfo.toString());
152                }
153                mTimeZones.add(tzInfo);
154            } else {
155                if (DEBUG) {
156                    Log.e(TAG,
157                            "# Dropping identical time zone from getAvailId: " + tzInfo.toString());
158                }
159                continue;
160            }
161            //
162            // TODO check for dups
163            // checkForNameDups(tz, tzInfo.mCountry, false /* dls */,
164            // TimeZone.SHORT, groupIdx, !found);
165            // checkForNameDups(tz, tzInfo.mCountry, false /* dls */,
166            // TimeZone.LONG, groupIdx, !found);
167            // if (tz.useDaylightTime()) {
168            // checkForNameDups(tz, tzInfo.mCountry, true /* dls */,
169            // TimeZone.SHORT, groupIdx,
170            // !found);
171            // checkForNameDups(tz, tzInfo.mCountry, true /* dls */,
172            // TimeZone.LONG, groupIdx,
173            // !found);
174            // }
175        }
176
177        // Don't change the order of mTimeZones after this sort
178        Collections.sort(mTimeZones);
179
180        mTimeZonesByCountry = new LinkedHashMap<String, ArrayList<Integer>>();
181        mTimeZonesByOffsets = new SparseArray<ArrayList<Integer>>(mHasTimeZonesInHrOffset.length);
182        mTimeZonesById = new HashMap<String, TimeZoneInfo>(mTimeZones.size());
183        for (TimeZoneInfo tz : mTimeZones) {
184            // /////////////////////
185            // Lookup map for id -> tz
186            mTimeZonesById.put(tz.mTzId, tz);
187        }
188        populateDisplayNameOverrides(mContext.getResources());
189
190        Date date = new Date(mTimeMillis);
191        Locale defaultLocal = Locale.getDefault();
192
193        int idx = 0;
194        for (TimeZoneInfo tz : mTimeZones) {
195            // /////////////////////
196            // Populate display name
197            if (tz.mDisplayName == null) {
198                tz.mDisplayName = tz.mTz.getDisplayName(tz.mTz.inDaylightTime(date),
199                        TimeZone.LONG, defaultLocal);
200            }
201
202            // /////////////////////
203            // Grouping tz's by country for search by country
204            ArrayList<Integer> group = mTimeZonesByCountry.get(tz.mCountry);
205            if (group == null) {
206                group = new ArrayList<Integer>();
207                mTimeZonesByCountry.put(tz.mCountry, group);
208            }
209
210            group.add(idx);
211
212            // /////////////////////
213            // Grouping tz's by GMT offsets
214            indexByOffsets(idx, tz);
215
216            // Skip all the GMT+xx:xx style display names from search
217            if (!tz.mDisplayName.endsWith(":00")) {
218                mTimeZoneNames.add(tz.mDisplayName);
219            } else if (DEBUG) {
220                Log.e(TAG, "# Hiding from pretty name search: " +
221                        tz.mDisplayName);
222            }
223
224            idx++;
225        }
226
227        // printTimeZones();
228    }
229
230    private void printTimeZones() {
231        TimeZoneInfo last = null;
232        boolean first = true;
233        for (TimeZoneInfo tz : mTimeZones) {
234            // All
235            if (false) {
236                Log.e("ALL", tz.toString());
237            }
238
239            // GMT
240            if (true) {
241                String name = tz.mTz.getDisplayName();
242                if (name.startsWith("GMT") && !tz.mTzId.startsWith("Etc/GMT")) {
243                    Log.e("GMT", tz.toString());
244                }
245            }
246
247            // Dups
248            if (true && last != null) {
249                if (last.compareTo(tz) == 0) {
250                    if (first) {
251                        Log.e("SAME", last.toString());
252                        first = false;
253                    }
254                    Log.e("SAME", tz.toString());
255                } else {
256                    first = true;
257                }
258            }
259            last = tz;
260        }
261        Log.e(TAG, "Total number of tz's = " + mTimeZones.size());
262    }
263
264    private void populateDisplayNameOverrides(Resources resources) {
265        String[] ids = resources.getStringArray(R.array.timezone_rename_ids);
266        String[] labels = resources.getStringArray(R.array.timezone_rename_labels);
267
268        int length = ids.length;
269        if (ids.length != labels.length) {
270            Log.e(TAG, "timezone_rename_ids len=" + ids.length + " timezone_rename_labels len="
271                    + labels.length);
272            length = Math.min(ids.length, labels.length);
273        }
274
275        for (int i = 0; i < length; i++) {
276            TimeZoneInfo tzi = mTimeZonesById.get(ids[i]);
277            if (tzi != null) {
278                tzi.mDisplayName = labels[i];
279            } else {
280                Log.e(TAG, "Could not find timezone with label: "+labels[i]);
281            }
282        }
283    }
284
285    public boolean hasTimeZonesInHrOffset(int offsetHr) {
286        int index = OFFSET_ARRAY_OFFSET + offsetHr;
287        if (index >= mHasTimeZonesInHrOffset.length || index < 0) {
288            return false;
289        }
290        return mHasTimeZonesInHrOffset[index];
291    }
292
293    private void indexByOffsets(int idx, TimeZoneInfo tzi) {
294        int offsetMillis = tzi.getNowOffsetMillis();
295        int index = OFFSET_ARRAY_OFFSET + (int) (offsetMillis / DateUtils.HOUR_IN_MILLIS);
296        mHasTimeZonesInHrOffset[index] = true;
297
298        ArrayList<Integer> group = mTimeZonesByOffsets.get(index);
299        if (group == null) {
300            group = new ArrayList<Integer>();
301            mTimeZonesByOffsets.put(index, group);
302        }
303        group.add(idx);
304    }
305
306    public ArrayList<Integer> getTimeZonesByOffset(int offsetHr) {
307        int index = OFFSET_ARRAY_OFFSET + offsetHr;
308        if (index >= mHasTimeZonesInHrOffset.length || index < 0) {
309            return null;
310        }
311        return mTimeZonesByOffsets.get(index);
312    }
313
314    private HashSet<String> loadTzsInZoneTab(Context context) {
315        HashSet<String> processedTimeZones = new HashSet<String>();
316        AssetManager am = context.getAssets();
317        InputStream is = null;
318
319        /*
320         * The 'backward' file contain mappings between new and old time zone
321         * ids. We will explicitly ignore the old ones.
322         */
323        try {
324            is = am.open("backward");
325            BufferedReader reader = new BufferedReader(new InputStreamReader(is));
326            String line;
327
328            while ((line = reader.readLine()) != null) {
329                // Skip comment lines
330                if (!line.startsWith("#") && line.length() > 0) {
331                    // 0: "Link"
332                    // 1: New tz id
333                    // Last: Old tz id
334                    String[] fields = line.split("\t+");
335                    String newTzId = fields[1];
336                    String oldTzId = fields[fields.length - 1];
337
338                    final TimeZone tz = TimeZone.getTimeZone(newTzId);
339                    if (tz == null) {
340                        Log.e(TAG, "Timezone not found: " + newTzId);
341                        continue;
342                    }
343
344                    processedTimeZones.add(oldTzId);
345
346                    if (DEBUG) {
347                        Log.e(TAG, "# Dropping identical time zone from backward: " + oldTzId);
348                    }
349
350                    // Remember the cooler/newer time zone id
351                    if (mDefaultTimeZoneId != null && mDefaultTimeZoneId.equals(oldTzId)) {
352                        mAlternateDefaultTimeZoneId = newTzId;
353                    }
354                }
355            }
356        } catch (IOException ex) {
357            Log.e(TAG, "Failed to read 'backward' file.");
358        } finally {
359            try {
360                if (is != null) {
361                    is.close();
362                }
363            } catch (IOException ignored) {
364            }
365        }
366
367        /*
368         * zone.tab contains a list of time zones and country code. They are
369         * "sorted first by country, then an order within the country that (1)
370         * makes some geographical sense, and (2) puts the most populous zones
371         * first, where that does not contradict (1)."
372         */
373        try {
374            String lang = Locale.getDefault().getLanguage();
375            is = am.open("zone.tab");
376            BufferedReader reader = new BufferedReader(new InputStreamReader(is));
377            String line;
378            while ((line = reader.readLine()) != null) {
379                if (!line.startsWith("#")) { // Skip comment lines
380                    // 0: country code
381                    // 1: coordinates
382                    // 2: time zone id
383                    // 3: comments
384                    final String[] fields = line.split("\t");
385                    final String timeZoneId = fields[2];
386                    final String countryCode = fields[0];
387                    final TimeZone tz = TimeZone.getTimeZone(timeZoneId);
388                    if (tz == null) {
389                        Log.e(TAG, "Timezone not found: " + timeZoneId);
390                        continue;
391                    }
392
393                    /*
394                     * Dropping non-GMT tzs without a country code. They are not
395                     * really needed and they are dups but missing proper
396                     * country codes. e.g. WET CET MST7MDT PST8PDT Asia/Khandyga
397                     * Asia/Ust-Nera EST
398                     */
399                    if (countryCode == null && !timeZoneId.startsWith("Etc/GMT")) {
400                        processedTimeZones.add(timeZoneId);
401                        continue;
402                    }
403
404                    // Remember the mapping between the country code and display
405                    // name
406                    String country = mCountryCodeToNameMap.get(countryCode);
407                    if (country == null) {
408                        country = getCountryNames(lang, countryCode);
409                        mCountryCodeToNameMap.put(countryCode, country);
410                    }
411
412                    // TODO Don't like this here but need to get the country of
413                    // the default tz.
414
415                    // Find the country of the default tz
416                    if (mDefaultTimeZoneId != null && mDefaultTimeZoneCountry == null
417                            && timeZoneId.equals(mAlternateDefaultTimeZoneId)) {
418                        mDefaultTimeZoneCountry = country;
419                        TimeZone defaultTz = TimeZone.getTimeZone(mDefaultTimeZoneId);
420                        if (defaultTz != null) {
421                            mDefaultTimeZoneInfo = new TimeZoneInfo(defaultTz, country);
422
423                            int tzToOverride = getIdenticalTimeZoneInTheCountry(mDefaultTimeZoneInfo);
424                            if (tzToOverride == -1) {
425                                if (DEBUG) {
426                                    Log.e(TAG, "Adding default time zone: "
427                                            + mDefaultTimeZoneInfo.toString());
428                                }
429                                mTimeZones.add(mDefaultTimeZoneInfo);
430                            } else {
431                                mTimeZones.add(tzToOverride, mDefaultTimeZoneInfo);
432                                if (DEBUG) {
433                                    TimeZoneInfo tzInfoToOverride = mTimeZones.get(tzToOverride);
434                                    String tzIdToOverride = tzInfoToOverride.mTzId;
435                                    Log.e(TAG, "Replaced by default tz: "
436                                            + tzInfoToOverride.toString());
437                                    Log.e(TAG, "Adding default time zone: "
438                                            + mDefaultTimeZoneInfo.toString());
439                                }
440                            }
441                        }
442                    }
443
444                    // Add to the list of time zones if the time zone is unique
445                    // in the given country.
446                    TimeZoneInfo timeZoneInfo = new TimeZoneInfo(tz, country);
447                    int identicalTzIdx = getIdenticalTimeZoneInTheCountry(timeZoneInfo);
448                    if (identicalTzIdx == -1) {
449                        if (DEBUG) {
450                            Log.e(TAG, "# Adding time zone: " + timeZoneId + " ## " +
451                                    tz.getDisplayName());
452                        }
453                        mTimeZones.add(timeZoneInfo);
454                    } else {
455                        if (DEBUG) {
456                            Log.e(TAG, "# Dropping identical time zone: " + timeZoneId + " ## " +
457                                    tz.getDisplayName());
458                        }
459                    }
460                    processedTimeZones.add(timeZoneId);
461                }
462            }
463
464        } catch (IOException ex) {
465            Log.e(TAG, "Failed to read 'zone.tab'.");
466        } finally {
467            try {
468                if (is != null) {
469                    is.close();
470                }
471            } catch (IOException ignored) {
472            }
473        }
474
475        return processedTimeZones;
476    }
477
478    private static Locale mBackupCountryLocale;
479    private static String[] mBackupCountryCodes;
480    private static String[] mBackupCountryNames;
481
482    private String getCountryNames(String lang, String countryCode) {
483        final Locale defaultLocale = Locale.getDefault();
484        String countryDisplayName;
485        if (PALESTINE_COUNTRY_CODE.equalsIgnoreCase(countryCode)) {
486            countryDisplayName = mPalestineDisplayName;
487        } else {
488            countryDisplayName = new Locale(lang, countryCode).getDisplayCountry(defaultLocale);
489        }
490
491        if (!countryCode.equals(countryDisplayName)) {
492            return countryDisplayName;
493        }
494
495        if (mBackupCountryCodes == null || !defaultLocale.equals(mBackupCountryLocale)) {
496            mBackupCountryLocale = defaultLocale;
497            mBackupCountryCodes = mContext.getResources().getStringArray(
498                    R.array.backup_country_codes);
499            mBackupCountryNames = mContext.getResources().getStringArray(
500                    R.array.backup_country_names);
501        }
502
503        int length = Math.min(mBackupCountryCodes.length, mBackupCountryNames.length);
504
505        for (int i = 0; i < length; i++) {
506            if (mBackupCountryCodes[i].equals(countryCode)) {
507                return mBackupCountryNames[i];
508            }
509        }
510
511        return countryCode;
512    }
513
514    private int getIdenticalTimeZoneInTheCountry(TimeZoneInfo timeZoneInfo) {
515        int idx = 0;
516        for (TimeZoneInfo tzi : mTimeZones) {
517            if (tzi.hasSameRules(timeZoneInfo)) {
518                if (tzi.mCountry == null) {
519                    if (timeZoneInfo.mCountry == null) {
520                        return idx;
521                    }
522                } else if (tzi.mCountry.equals(timeZoneInfo.mCountry)) {
523                    return idx;
524                }
525            }
526            ++idx;
527        }
528        return -1;
529    }
530}
531