1 /* 2 * Copyright (C) 2014 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.inputmethod.latin; 18 19 import android.content.Context; 20 import android.database.Cursor; 21 import android.database.sqlite.SQLiteException; 22 import android.net.Uri; 23 import android.provider.ContactsContract.Contacts; 24 import android.text.TextUtils; 25 import android.util.Log; 26 27 import com.android.inputmethod.latin.common.Constants; 28 import com.android.inputmethod.latin.common.StringUtils; 29 30 import java.util.ArrayList; 31 import java.util.Collections; 32 import java.util.Comparator; 33 import java.util.HashSet; 34 import java.util.concurrent.TimeUnit; 35 import java.util.concurrent.atomic.AtomicInteger; 36 37 /** 38 * Manages all interactions with Contacts DB. 39 * 40 * The manager provides an API for listening to meaning full updates by keeping a 41 * measure of the current state of the content provider. 42 */ 43 public class ContactsManager { 44 private static final String TAG = "ContactsManager"; 45 46 /** 47 * Use at most this many of the highest affinity contacts. 48 */ 49 public static final int MAX_CONTACT_NAMES = 200; 50 51 protected static class RankedContact { 52 public final String mName; 53 public final long mLastContactedTime; 54 public final int mTimesContacted; 55 public final boolean mInVisibleGroup; 56 57 private float mAffinity = 0.0f; 58 RankedContact(final Cursor cursor)59 RankedContact(final Cursor cursor) { 60 mName = cursor.getString( 61 ContactsDictionaryConstants.NAME_INDEX); 62 mTimesContacted = cursor.getInt( 63 ContactsDictionaryConstants.TIMES_CONTACTED_INDEX); 64 mLastContactedTime = cursor.getLong( 65 ContactsDictionaryConstants.LAST_TIME_CONTACTED_INDEX); 66 mInVisibleGroup = cursor.getInt( 67 ContactsDictionaryConstants.IN_VISIBLE_GROUP_INDEX) == 1; 68 } 69 getAffinity()70 float getAffinity() { 71 return mAffinity; 72 } 73 74 /** 75 * Calculates the affinity with the contact based on: 76 * - How many times it has been contacted 77 * - How long since the last contact. 78 * - Whether the contact is in the visible group (i.e., Contacts list). 79 * 80 * Note: This affinity is limited by the fact that some apps currently do not update the 81 * LAST_TIME_CONTACTED or TIMES_CONTACTED counters. As a result, a frequently messaged 82 * contact may still have 0 affinity. 83 */ computeAffinity(final int maxTimesContacted, final long currentTime)84 void computeAffinity(final int maxTimesContacted, final long currentTime) { 85 final float timesWeight = ((float) mTimesContacted + 1) / (maxTimesContacted + 1); 86 final long timeSinceLastContact = Math.min( 87 Math.max(0, currentTime - mLastContactedTime), 88 TimeUnit.MILLISECONDS.convert(180, TimeUnit.DAYS)); 89 final float lastTimeWeight = (float) Math.pow(0.5, 90 timeSinceLastContact / (TimeUnit.MILLISECONDS.convert(10, TimeUnit.DAYS))); 91 final float visibleWeight = mInVisibleGroup ? 1.0f : 0.0f; 92 mAffinity = (timesWeight + lastTimeWeight + visibleWeight) / 3; 93 } 94 } 95 96 private static class AffinityComparator implements Comparator<RankedContact> { 97 @Override compare(RankedContact contact1, RankedContact contact2)98 public int compare(RankedContact contact1, RankedContact contact2) { 99 return Float.compare(contact2.getAffinity(), contact1.getAffinity()); 100 } 101 } 102 103 /** 104 * Interface to implement for classes interested in getting notified for updates 105 * to Contacts content provider. 106 */ 107 public static interface ContactsChangedListener { onContactsChange()108 public void onContactsChange(); 109 } 110 111 /** 112 * The number of contacts observed in the most recent instance of 113 * contacts content provider. 114 */ 115 private AtomicInteger mContactCountAtLastRebuild = new AtomicInteger(0); 116 117 /** 118 * The hash code of list of valid contacts names in the most recent dictionary 119 * rebuild. 120 */ 121 private AtomicInteger mHashCodeAtLastRebuild = new AtomicInteger(0); 122 123 private final Context mContext; 124 private final ContactsContentObserver mObserver; 125 ContactsManager(final Context context)126 public ContactsManager(final Context context) { 127 mContext = context; 128 mObserver = new ContactsContentObserver(this /* ContactsManager */, context); 129 } 130 131 // TODO: This was synchronized in previous version. Why? registerForUpdates(final ContactsChangedListener listener)132 public void registerForUpdates(final ContactsChangedListener listener) { 133 mObserver.registerObserver(listener); 134 } 135 getContactCountAtLastRebuild()136 public int getContactCountAtLastRebuild() { 137 return mContactCountAtLastRebuild.get(); 138 } 139 getHashCodeAtLastRebuild()140 public int getHashCodeAtLastRebuild() { 141 return mHashCodeAtLastRebuild.get(); 142 } 143 144 /** 145 * Returns all the valid names in the Contacts DB. Callers should also 146 * call {@link #updateLocalState(ArrayList)} after they are done with result 147 * so that the manager can cache local state for determining updates. 148 * 149 * These names are sorted by their affinity to the user, with favorite 150 * contacts appearing first. 151 */ getValidNames(final Uri uri)152 public ArrayList<String> getValidNames(final Uri uri) { 153 // Check all contacts since it's not possible to find out which names have changed. 154 // This is needed because it's possible to receive extraneous onChange events even when no 155 // name has changed. 156 final Cursor cursor = mContext.getContentResolver().query(uri, 157 ContactsDictionaryConstants.PROJECTION, null, null, null); 158 final ArrayList<RankedContact> contacts = new ArrayList<>(); 159 int maxTimesContacted = 0; 160 if (cursor != null) { 161 try { 162 if (cursor.moveToFirst()) { 163 while (!cursor.isAfterLast()) { 164 final String name = cursor.getString( 165 ContactsDictionaryConstants.NAME_INDEX); 166 if (isValidName(name)) { 167 final int timesContacted = cursor.getInt( 168 ContactsDictionaryConstants.TIMES_CONTACTED_INDEX); 169 if (timesContacted > maxTimesContacted) { 170 maxTimesContacted = timesContacted; 171 } 172 contacts.add(new RankedContact(cursor)); 173 } 174 cursor.moveToNext(); 175 } 176 } 177 } finally { 178 cursor.close(); 179 } 180 } 181 final long currentTime = System.currentTimeMillis(); 182 for (RankedContact contact : contacts) { 183 contact.computeAffinity(maxTimesContacted, currentTime); 184 } 185 Collections.sort(contacts, new AffinityComparator()); 186 final HashSet<String> names = new HashSet<>(); 187 for (int i = 0; i < contacts.size() && names.size() < MAX_CONTACT_NAMES; ++i) { 188 names.add(contacts.get(i).mName); 189 } 190 return new ArrayList<>(names); 191 } 192 193 /** 194 * Returns the number of contacts in contacts content provider. 195 */ getContactCount()196 public int getContactCount() { 197 // TODO: consider switching to a rawQuery("select count(*)...") on the database if 198 // performance is a bottleneck. 199 Cursor cursor = null; 200 try { 201 cursor = mContext.getContentResolver().query(Contacts.CONTENT_URI, 202 ContactsDictionaryConstants.PROJECTION_ID_ONLY, null, null, null); 203 if (null == cursor) { 204 return 0; 205 } 206 return cursor.getCount(); 207 } catch (final SQLiteException e) { 208 Log.e(TAG, "SQLiteException in the remote Contacts process.", e); 209 } finally { 210 if (null != cursor) { 211 cursor.close(); 212 } 213 } 214 return 0; 215 } 216 isValidName(final String name)217 private static boolean isValidName(final String name) { 218 if (TextUtils.isEmpty(name) || name.indexOf(Constants.CODE_COMMERCIAL_AT) != -1) { 219 return false; 220 } 221 final boolean hasSpace = name.indexOf(Constants.CODE_SPACE) != -1; 222 if (!hasSpace) { 223 // Only allow an isolated word if it does not contain a hyphen. 224 // This helps to filter out mailing lists. 225 return name.indexOf(Constants.CODE_DASH) == -1; 226 } 227 return true; 228 } 229 230 /** 231 * Updates the local state of the manager. This should be called when the callers 232 * are done with all the updates of the content provider successfully. 233 */ updateLocalState(final ArrayList<String> names)234 public void updateLocalState(final ArrayList<String> names) { 235 mContactCountAtLastRebuild.set(getContactCount()); 236 mHashCodeAtLastRebuild.set(names.hashCode()); 237 } 238 239 /** 240 * Performs any necessary cleanup. 241 */ close()242 public void close() { 243 mObserver.unregister(); 244 } 245 } 246