1 /*
2  * Copyright (C) 2011 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.calendar.selectcalendars;
18 
19 import android.accounts.AccountManager;
20 import android.accounts.AuthenticatorDescription;
21 import android.app.FragmentManager;
22 import android.content.AsyncQueryHandler;
23 import android.content.ContentResolver;
24 import android.content.ContentUris;
25 import android.content.ContentValues;
26 import android.content.Context;
27 import android.content.pm.PackageManager;
28 import android.database.Cursor;
29 import android.database.MatrixCursor;
30 import android.graphics.Rect;
31 import android.net.Uri;
32 import android.provider.CalendarContract.Calendars;
33 import android.text.TextUtils;
34 import android.util.Log;
35 import android.view.LayoutInflater;
36 import android.view.TouchDelegate;
37 import android.view.View;
38 import android.view.View.OnClickListener;
39 import android.view.ViewGroup;
40 import android.widget.CheckBox;
41 import android.widget.CursorTreeAdapter;
42 import android.widget.TextView;
43 
44 import com.android.calendar.CalendarColorPickerDialog;
45 import com.android.calendar.R;
46 import com.android.calendar.Utils;
47 import com.android.calendar.selectcalendars.CalendarColorCache.OnCalendarColorsLoadedListener;
48 
49 import java.util.HashMap;
50 import java.util.Iterator;
51 import java.util.Map;
52 
53 public class SelectSyncedCalendarsMultiAccountAdapter extends CursorTreeAdapter implements
54         View.OnClickListener, OnCalendarColorsLoadedListener {
55 
56     private static final String TAG = "Calendar";
57     private static final String COLOR_PICKER_DIALOG_TAG = "ColorPickerDialog";
58 
59     private static final String IS_PRIMARY = "\"primary\"";
60     private static final String CALENDARS_ORDERBY = IS_PRIMARY + " DESC,"
61             + Calendars.CALENDAR_DISPLAY_NAME + " COLLATE NOCASE";
62     private static final String ACCOUNT_SELECTION = Calendars.ACCOUNT_NAME + "=?"
63             + " AND " + Calendars.ACCOUNT_TYPE + "=?";
64 
65     private final LayoutInflater mInflater;
66     private final ContentResolver mResolver;
67     private final SelectSyncedCalendarsMultiAccountActivity mActivity;
68     private final FragmentManager mFragmentManager;
69     private final boolean mIsTablet;
70     private CalendarColorPickerDialog mColorPickerDialog;
71     private final View mView;
72     private final static Runnable mStopRefreshing = new Runnable() {
73         @Override
74         public void run() {
75             mRefresh = false;
76         }
77     };
78     private Map<String, AuthenticatorDescription> mTypeToAuthDescription
79         = new HashMap<String, AuthenticatorDescription>();
80     protected AuthenticatorDescription[] mAuthDescs;
81 
82     // These track changes to the synced state of calendars
83     private Map<Long, Boolean> mCalendarChanges
84         = new HashMap<Long, Boolean>();
85     private Map<Long, Boolean> mCalendarInitialStates
86         = new HashMap<Long, Boolean>();
87 
88     // Flag for when the cursors have all been closed to ensure no race condition with queries.
89     private boolean mClosedCursorsFlag;
90 
91     // This is for keeping MatrixCursor copies so that we can requery in the background.
92     private Map<String, Cursor> mChildrenCursors
93         = new HashMap<String, Cursor>();
94 
95     private AsyncCalendarsUpdater mCalendarsUpdater;
96     // This is to keep our update tokens separate from other tokens. Since we cancel old updates
97     // when a new update comes in, we'd like to leave a token space that won't be canceled.
98     private static final int MIN_UPDATE_TOKEN = 1000;
99     private static int mUpdateToken = MIN_UPDATE_TOKEN;
100     // How long to wait between requeries of the calendars to see if anything has changed.
101     private static final int REFRESH_DELAY = 5000;
102     // How long to keep refreshing for
103     private static final int REFRESH_DURATION = 60000;
104     private static boolean mRefresh = true;
105 
106     private static String mSyncedText;
107     private static String mNotSyncedText;
108 
109     // This is to keep track of whether or not multiple calendars have the same display name
110     private static HashMap<String, Boolean> mIsDuplicateName = new HashMap<String, Boolean>();
111 
112     private int mColorViewTouchAreaIncrease;
113 
114     private static final String[] PROJECTION = new String[] {
115       Calendars._ID,
116       Calendars.ACCOUNT_NAME,
117       Calendars.OWNER_ACCOUNT,
118       Calendars.CALENDAR_DISPLAY_NAME,
119       Calendars.CALENDAR_COLOR,
120       Calendars.VISIBLE,
121       Calendars.SYNC_EVENTS,
122       "(" + Calendars.ACCOUNT_NAME + "=" + Calendars.OWNER_ACCOUNT + ") AS " + IS_PRIMARY,
123       Calendars.ACCOUNT_TYPE
124     };
125     //Keep these in sync with the projection
126     private static final int ID_COLUMN = 0;
127     private static final int ACCOUNT_COLUMN = 1;
128     private static final int OWNER_COLUMN = 2;
129     private static final int NAME_COLUMN = 3;
130     private static final int COLOR_COLUMN = 4;
131     private static final int SELECTED_COLUMN = 5;
132     private static final int SYNCED_COLUMN = 6;
133     private static final int PRIMARY_COLUMN = 7;
134     private static final int ACCOUNT_TYPE_COLUMN = 8;
135 
136     private static final int TAG_ID_CALENDAR_ID = R.id.calendar;
137     private static final int TAG_ID_SYNC_CHECKBOX = R.id.sync;
138 
139     private CalendarColorCache mCache;
140 
141     private class AsyncCalendarsUpdater extends AsyncQueryHandler {
142 
AsyncCalendarsUpdater(ContentResolver cr)143         public AsyncCalendarsUpdater(ContentResolver cr) {
144             super(cr);
145         }
146 
147         @Override
onQueryComplete(int token, Object cookie, Cursor cursor)148         protected void onQueryComplete(int token, Object cookie, Cursor cursor) {
149             if(cursor == null) {
150                 return;
151             }
152             synchronized(mChildrenCursors) {
153                 if (mClosedCursorsFlag || (mActivity != null && mActivity.isFinishing())) {
154                     cursor.close();
155                     return;
156                 }
157             }
158 
159             Cursor currentCursor = mChildrenCursors.get(cookie);
160             // Check if the new cursor has the same content as our old cursor
161             if (currentCursor != null) {
162                 if (Utils.compareCursors(currentCursor, cursor)) {
163                     cursor.close();
164                     return;
165                 }
166             }
167             // If not then make a new matrix cursor for our Map
168             MatrixCursor newCursor = Utils.matrixCursorFromCursor(cursor);
169             cursor.close();
170             // And update our list of duplicated names
171             Utils.checkForDuplicateNames(mIsDuplicateName, newCursor, NAME_COLUMN);
172 
173             mChildrenCursors.put((String)cookie, newCursor);
174             try {
175                 setChildrenCursor(token, newCursor);
176             } catch (NullPointerException e) {
177                 Log.w(TAG, "Adapter expired, try again on the next query: " + e);
178             }
179             // Clean up our old cursor if we had one. We have to do this after setting the new
180             // cursor so that our view doesn't throw on an invalid cursor.
181             if (currentCursor != null) {
182                 currentCursor.close();
183             }
184         }
185     }
186 
187     /**
188      * Method for changing the sync state when a calendar's button is pressed.
189      *
190      * This gets called when the CheckBox for a calendar is clicked. It toggles
191      * the sync state for the associated calendar and saves a change of state to
192      * a hashmap. It also compares against the original value and removes any
193      * changes from the hashmap if this is back at its initial state.
194      */
195     @Override
onClick(View v)196     public void onClick(View v) {
197         long id = (Long) v.getTag(TAG_ID_CALENDAR_ID);
198         boolean newState;
199         boolean initialState = mCalendarInitialStates.get(id);
200         if (mCalendarChanges.containsKey(id)) {
201             // Negate to reflect the click
202             newState = !mCalendarChanges.get(id);
203         } else {
204             // Negate to reflect the click
205             newState = !initialState;
206         }
207 
208         if (newState == initialState) {
209             mCalendarChanges.remove(id);
210         } else {
211             mCalendarChanges.put(id, newState);
212         }
213 
214         ((CheckBox) v.getTag(TAG_ID_SYNC_CHECKBOX)).setChecked(newState);
215         setText(v, R.id.status, newState ? mSyncedText : mNotSyncedText);
216     }
217 
SelectSyncedCalendarsMultiAccountAdapter(Context context, Cursor acctsCursor, SelectSyncedCalendarsMultiAccountActivity act)218     public SelectSyncedCalendarsMultiAccountAdapter(Context context, Cursor acctsCursor,
219             SelectSyncedCalendarsMultiAccountActivity act) {
220         super(acctsCursor, context);
221         mSyncedText = context.getString(R.string.synced);
222         mNotSyncedText = context.getString(R.string.not_synced);
223 
224         mCache = new CalendarColorCache(context, this);
225 
226         mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
227         mResolver = context.getContentResolver();
228         mActivity = act;
229         mFragmentManager = act.getFragmentManager();
230         mColorPickerDialog = (CalendarColorPickerDialog)
231                 mFragmentManager.findFragmentByTag(COLOR_PICKER_DIALOG_TAG);
232         mIsTablet = Utils.getConfigBool(context, R.bool.tablet_config);
233 
234         if (mCalendarsUpdater == null) {
235             mCalendarsUpdater = new AsyncCalendarsUpdater(mResolver);
236         }
237 
238         if (acctsCursor == null || acctsCursor.getCount() == 0) {
239             Log.i(TAG, "SelectCalendarsAdapter: No accounts were returned!");
240         }
241         // Collect proper description for account types
242         mAuthDescs = AccountManager.get(context).getAuthenticatorTypes();
243         for (int i = 0; i < mAuthDescs.length; i++) {
244             mTypeToAuthDescription.put(mAuthDescs[i].type, mAuthDescs[i]);
245         }
246         mView = mActivity.getExpandableListView();
247         mRefresh = true;
248         mClosedCursorsFlag = false;
249 
250         mColorViewTouchAreaIncrease = context.getResources()
251                 .getDimensionPixelSize(R.dimen.color_view_touch_area_increase);
252     }
253 
startRefreshStopDelay()254     public void startRefreshStopDelay() {
255         mRefresh = true;
256         mView.postDelayed(mStopRefreshing, REFRESH_DURATION);
257     }
258 
cancelRefreshStopDelay()259     public void cancelRefreshStopDelay() {
260         mView.removeCallbacks(mStopRefreshing);
261     }
262 
263     /*
264      * Write back the changes that have been made. The sync code will pick up any changes and
265      * do updates on its own.
266      */
doSaveAction()267     public void doSaveAction() {
268         // Cancel the previous operation
269         mCalendarsUpdater.cancelOperation(mUpdateToken);
270         mUpdateToken++;
271         // This is to allow us to do queries and updates with the same AsyncQueryHandler without
272         // accidently canceling queries.
273         if(mUpdateToken < MIN_UPDATE_TOKEN) {
274             mUpdateToken = MIN_UPDATE_TOKEN;
275         }
276 
277         Iterator<Long> changeKeys = mCalendarChanges.keySet().iterator();
278         while (changeKeys.hasNext()) {
279             long id = changeKeys.next();
280             boolean newSynced = mCalendarChanges.get(id);
281 
282             Uri uri = ContentUris.withAppendedId(Calendars.CONTENT_URI, id);
283             ContentValues values = new ContentValues();
284             values.put(Calendars.VISIBLE, newSynced ? 1 : 0);
285             values.put(Calendars.SYNC_EVENTS, newSynced ? 1 : 0);
286             mCalendarsUpdater.startUpdate(mUpdateToken, id, uri, values, null, null);
287         }
288     }
289 
setText(View view, int id, String text)290     private static void setText(View view, int id, String text) {
291         if (TextUtils.isEmpty(text)) {
292             return;
293         }
294         TextView textView = (TextView) view.findViewById(id);
295         textView.setText(text);
296     }
297 
298     /**
299      * Gets the label associated with a particular account type. If none found, return null.
300      * @param accountType the type of account
301      * @return a CharSequence for the label or null if one cannot be found.
302      */
getLabelForType(final String accountType)303     protected CharSequence getLabelForType(final String accountType) {
304         CharSequence label = null;
305         if (mTypeToAuthDescription.containsKey(accountType)) {
306              try {
307                  AuthenticatorDescription desc = mTypeToAuthDescription.get(accountType);
308                  Context authContext = mActivity.createPackageContext(desc.packageName, 0);
309                  label = authContext.getResources().getText(desc.labelId);
310              } catch (PackageManager.NameNotFoundException e) {
311                  Log.w(TAG, "No label for account type " + ", type " + accountType);
312              }
313         }
314         return label;
315     }
316 
317     @Override
bindChildView(View view, Context context, Cursor cursor, boolean isLastChild)318     protected void bindChildView(View view, Context context, Cursor cursor, boolean isLastChild) {
319         final long id = cursor.getLong(ID_COLUMN);
320         String name = cursor.getString(NAME_COLUMN);
321         String owner = cursor.getString(OWNER_COLUMN);
322         final String accountName = cursor.getString(ACCOUNT_COLUMN);
323         final String accountType = cursor.getString(ACCOUNT_TYPE_COLUMN);
324         int color = Utils.getDisplayColorFromColor(cursor.getInt(COLOR_COLUMN));
325 
326         final View colorSquare = view.findViewById(R.id.color);
327         colorSquare.setEnabled(mCache.hasColors(accountName, accountType));
328         colorSquare.setBackgroundColor(color);
329         final View delegateParent = (View) colorSquare.getParent();
330         delegateParent.post(new Runnable() {
331 
332             @Override
333             public void run() {
334                 final Rect r = new Rect();
335                 colorSquare.getHitRect(r);
336                 r.top -= mColorViewTouchAreaIncrease;
337                 r.bottom += mColorViewTouchAreaIncrease;
338                 r.left -= mColorViewTouchAreaIncrease;
339                 r.right += mColorViewTouchAreaIncrease;
340                 delegateParent.setTouchDelegate(new TouchDelegate(r, colorSquare));
341             }
342         });
343         colorSquare.setOnClickListener(new OnClickListener() {
344 
345             @Override
346             public void onClick(View v) {
347                 if (!mCache.hasColors(accountName, accountType)) {
348                     return;
349                 }
350                 if (mColorPickerDialog == null) {
351                     mColorPickerDialog = CalendarColorPickerDialog.newInstance(id, mIsTablet);
352                 } else {
353                     mColorPickerDialog.setCalendarId(id);
354                 }
355                 mFragmentManager.executePendingTransactions();
356                 if (!mColorPickerDialog.isAdded()) {
357                     mColorPickerDialog.show(mFragmentManager, COLOR_PICKER_DIALOG_TAG);
358                 }
359             }
360         });
361         if (mIsDuplicateName.containsKey(name) && mIsDuplicateName.get(name) &&
362                 !name.equalsIgnoreCase(owner)) {
363             name = new StringBuilder(name)
364                     .append(Utils.OPEN_EMAIL_MARKER)
365                     .append(owner)
366                     .append(Utils.CLOSE_EMAIL_MARKER)
367                     .toString();
368         }
369         setText(view, R.id.calendar, name);
370 
371         // First see if the user has already changed the state of this calendar
372         Boolean sync = mCalendarChanges.get(id);
373         if (sync == null) {
374             sync = cursor.getInt(SYNCED_COLUMN) == 1;
375             mCalendarInitialStates.put(id, sync);
376         }
377 
378         CheckBox button = (CheckBox) view.findViewById(R.id.sync);
379         button.setChecked(sync);
380         setText(view, R.id.status, sync ? mSyncedText : mNotSyncedText);
381 
382         view.setTag(TAG_ID_CALENDAR_ID, id);
383         view.setTag(TAG_ID_SYNC_CHECKBOX, button);
384         view.setOnClickListener(this);
385     }
386 
387     @Override
bindGroupView(View view, Context context, Cursor cursor, boolean isExpanded)388     protected void bindGroupView(View view, Context context, Cursor cursor, boolean isExpanded) {
389         int accountColumn = cursor.getColumnIndexOrThrow(Calendars.ACCOUNT_NAME);
390         int accountTypeColumn = cursor.getColumnIndexOrThrow(Calendars.ACCOUNT_TYPE);
391         String account = cursor.getString(accountColumn);
392         String accountType = cursor.getString(accountTypeColumn);
393         CharSequence accountLabel = getLabelForType(accountType);
394         setText(view, R.id.account, account);
395         if (accountLabel != null) {
396             setText(view, R.id.account_type, accountLabel.toString());
397         }
398     }
399 
400     @Override
getChildrenCursor(Cursor groupCursor)401     protected Cursor getChildrenCursor(Cursor groupCursor) {
402         int accountColumn = groupCursor.getColumnIndexOrThrow(Calendars.ACCOUNT_NAME);
403         int accountTypeColumn = groupCursor.getColumnIndexOrThrow(Calendars.ACCOUNT_TYPE);
404         String account = groupCursor.getString(accountColumn);
405         String accountType = groupCursor.getString(accountTypeColumn);
406         //Get all the calendars for just this account.
407         Cursor childCursor = mChildrenCursors.get(accountType + "#" + account);
408         new RefreshCalendars(groupCursor.getPosition(), account, accountType).run();
409         return childCursor;
410     }
411 
412     @Override
newChildView(Context context, Cursor cursor, boolean isLastChild, ViewGroup parent)413     protected View newChildView(Context context, Cursor cursor, boolean isLastChild,
414             ViewGroup parent) {
415         return mInflater.inflate(R.layout.calendar_sync_item, parent, false);
416     }
417 
418     @Override
newGroupView(Context context, Cursor cursor, boolean isExpanded, ViewGroup parent)419     protected View newGroupView(Context context, Cursor cursor, boolean isExpanded,
420             ViewGroup parent) {
421         return mInflater.inflate(R.layout.account_item, parent, false);
422     }
423 
closeChildrenCursors()424     public void closeChildrenCursors() {
425         synchronized (mChildrenCursors) {
426             for (String key : mChildrenCursors.keySet()) {
427                 Cursor cursor = mChildrenCursors.get(key);
428                 if (!cursor.isClosed()) {
429                     cursor.close();
430                 }
431             }
432             mChildrenCursors.clear();
433             mClosedCursorsFlag = true;
434         }
435     }
436 
437     private class RefreshCalendars implements Runnable {
438 
439         int mToken;
440         String mAccount;
441         String mAccountType;
442 
RefreshCalendars(int token, String account, String accountType)443         public RefreshCalendars(int token, String account, String accountType) {
444             mToken = token;
445             mAccount = account;
446             mAccountType = accountType;
447         }
448 
449         @Override
run()450         public void run() {
451             mCalendarsUpdater.cancelOperation(mToken);
452             // Set up a refresh for some point in the future if we haven't stopped updates yet
453             if(mRefresh) {
454                 mView.postDelayed(new RefreshCalendars(mToken, mAccount, mAccountType),
455                         REFRESH_DELAY);
456             }
457             mCalendarsUpdater.startQuery(mToken,
458                     mAccountType + "#" + mAccount,
459                     Calendars.CONTENT_URI, PROJECTION,
460                     ACCOUNT_SELECTION,
461                     new String[] { mAccount, mAccountType } /*selectionArgs*/,
462                     CALENDARS_ORDERBY);
463         }
464     }
465 
466     @Override
onCalendarColorsLoaded()467     public void onCalendarColorsLoaded() {
468         notifyDataSetChanged();
469     }
470 }
471