1/*
2 * Copyright (C) 2015 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.providers.contacts.aggregation;
18
19import static com.android.providers.contacts.aggregation.util.RawContactMatcher.SCORE_THRESHOLD_PRIMARY;
20import static com.android.providers.contacts.aggregation.util.RawContactMatcher.SCORE_THRESHOLD_SECONDARY;
21import static com.android.providers.contacts.aggregation.util.RawContactMatcher.SCORE_THRESHOLD_SUGGEST;
22import android.database.Cursor;
23import android.database.sqlite.SQLiteDatabase;
24import android.provider.ContactsContract.AggregationExceptions;
25import android.provider.ContactsContract.CommonDataKinds.Email;
26import android.provider.ContactsContract.CommonDataKinds.Identity;
27import android.provider.ContactsContract.CommonDataKinds.Phone;
28import android.provider.ContactsContract.Contacts.AggregationSuggestions;
29import android.provider.ContactsContract.Data;
30import android.provider.ContactsContract.FullNameStyle;
31import android.provider.ContactsContract.PhotoFiles;
32import android.provider.ContactsContract.RawContacts;
33import android.text.TextUtils;
34import android.util.Log;
35import com.android.providers.contacts.ContactsDatabaseHelper;
36import com.android.providers.contacts.ContactsDatabaseHelper.DataColumns;
37import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupColumns;
38import com.android.providers.contacts.ContactsDatabaseHelper.NameLookupType;
39import com.android.providers.contacts.ContactsDatabaseHelper.PhoneLookupColumns;
40import com.android.providers.contacts.ContactsDatabaseHelper.RawContactsColumns;
41import com.android.providers.contacts.ContactsDatabaseHelper.Tables;
42import com.android.providers.contacts.ContactsProvider2;
43import com.android.providers.contacts.NameSplitter;
44import com.android.providers.contacts.PhotoPriorityResolver;
45import com.android.providers.contacts.TransactionContext;
46import com.android.providers.contacts.aggregation.util.CommonNicknameCache;
47import com.android.providers.contacts.aggregation.util.ContactAggregatorHelper;
48import com.android.providers.contacts.aggregation.util.MatchScore;
49import com.android.providers.contacts.aggregation.util.RawContactMatcher;
50import com.android.providers.contacts.aggregation.util.RawContactMatchingCandidates;
51import com.android.providers.contacts.database.ContactsTableUtil;
52import com.google.android.collect.Sets;
53
54import java.util.ArrayList;
55import java.util.HashSet;
56import java.util.List;
57import java.util.Map;
58import java.util.Set;
59
60/**
61 * ContactAggregator2 deals with aggregating contact information with sufficient matching data
62 * points. E.g., two John Doe contacts with same phone numbers are presumed to be the same
63 * person unless the user declares otherwise.
64 */
65public class ContactAggregator2 extends AbstractContactAggregator {
66
67    // Possible operation types for contacts aggregation.
68    private static final int CREATE_NEW_CONTACT = 1;
69    private static final int KEEP_INTACT = 0;
70    private static final int RE_AGGREGATE = -1;
71
72    private final RawContactMatcher mMatcher = new RawContactMatcher();
73
74    /**
75     * Constructor.
76     */
77    public ContactAggregator2(ContactsProvider2 contactsProvider,
78            ContactsDatabaseHelper contactsDatabaseHelper,
79            PhotoPriorityResolver photoPriorityResolver, NameSplitter nameSplitter,
80            CommonNicknameCache commonNicknameCache) {
81        super(contactsProvider, contactsDatabaseHelper, photoPriorityResolver, nameSplitter,
82                commonNicknameCache);
83    }
84
85    /**
86     * Given a specific raw contact, finds all matching raw contacts and re-aggregate them
87     * based on the matching connectivity.
88     */
89     synchronized void aggregateContact(TransactionContext txContext, SQLiteDatabase db,
90             long rawContactId, long accountId, long currentContactId,
91             MatchCandidateList candidates) {
92
93         if (!needAggregate(db, rawContactId)) {
94             if (VERBOSE_LOGGING) {
95                 Log.v(TAG, "Skip rid=" + rawContactId + " which has already been aggregated.");
96             }
97             return;
98         }
99
100         if (VERBOSE_LOGGING) {
101            Log.v(TAG, "aggregateContact: rid=" + rawContactId + " cid=" + currentContactId);
102        }
103
104        int aggregationMode = RawContacts.AGGREGATION_MODE_DEFAULT;
105
106        Integer aggModeObject = mRawContactsMarkedForAggregation.remove(rawContactId);
107        if (aggModeObject != null) {
108            aggregationMode = aggModeObject;
109        }
110
111        RawContactMatcher matcher = new RawContactMatcher();
112        RawContactMatchingCandidates matchingCandidates = new RawContactMatchingCandidates();
113        if (aggregationMode == RawContacts.AGGREGATION_MODE_DEFAULT) {
114            // If this is a newly inserted contact or a visible contact, look for
115            // data matches.
116            if (currentContactId == 0
117                    || mDbHelper.isContactInDefaultDirectory(db, currentContactId)) {
118                // Find the set of matching candidates
119                matchingCandidates = findRawContactMatchingCandidates(db, rawContactId, candidates,
120                        matcher);
121            }
122        } else if (aggregationMode == RawContacts.AGGREGATION_MODE_DISABLED) {
123            return;
124        }
125
126        // # of raw_contacts in the [currentContactId] contact excluding the [rawContactId]
127        // raw_contact.
128        long currentContactContentsCount = 0;
129
130        if (currentContactId != 0) {
131            mRawContactCountQuery.bindLong(1, currentContactId);
132            mRawContactCountQuery.bindLong(2, rawContactId);
133            currentContactContentsCount = mRawContactCountQuery.simpleQueryForLong();
134        }
135
136        // Set aggregation operation, i.e., re-aggregate, keep intact, or create new contact based
137        // on the number of matching candidates and the number of raw_contacts in the
138        // [currentContactId] excluding the [rawContactId].
139        final int operation;
140        final int candidatesCount = matchingCandidates.getCount();
141        if (candidatesCount >= AGGREGATION_CONTACT_SIZE_LIMIT) {
142            operation = KEEP_INTACT;
143            if (VERBOSE_LOGGING) {
144                Log.v(TAG, "Too many matching raw contacts (" + candidatesCount
145                        + ") are found, so skip aggregation");
146            }
147        } else if (candidatesCount > 0) {
148            operation = RE_AGGREGATE;
149        } else {
150            // When there is no matching raw contact found, if there are no other raw contacts in
151            // the current aggregate, we might as well reuse it. Also, if the aggregation mode is
152            // SUSPENDED, we must reuse the same aggregate.
153            if (currentContactId != 0
154                    && (currentContactContentsCount == 0
155                    || aggregationMode == RawContacts.AGGREGATION_MODE_SUSPENDED)) {
156                operation = KEEP_INTACT;
157            } else {
158                operation = CREATE_NEW_CONTACT;
159            }
160        }
161
162        if (operation == KEEP_INTACT) {
163            // Aggregation unchanged
164            if (VERBOSE_LOGGING) {
165                Log.v(TAG, "Aggregation unchanged");
166            }
167            markAggregated(db, String.valueOf(rawContactId));
168        } else if (operation == CREATE_NEW_CONTACT) {
169            // create new contact for [rawContactId]
170            if (VERBOSE_LOGGING) {
171                Log.v(TAG, "create new contact for rid=" + rawContactId);
172            }
173            createContactForRawContacts(db, txContext, Sets.newHashSet(rawContactId), null);
174            if (currentContactContentsCount > 0) {
175                updateAggregateData(txContext, currentContactId);
176            }
177            markAggregated(db, String.valueOf(rawContactId));
178        } else {
179            // re-aggregate
180            if (VERBOSE_LOGGING) {
181                Log.v(TAG, "Re-aggregating rids=" + rawContactId + ","
182                        + TextUtils.join(",", matchingCandidates.getRawContactIdSet()));
183            }
184            reAggregateRawContacts(txContext, db, currentContactId, rawContactId, accountId,
185                    currentContactContentsCount, matchingCandidates);
186        }
187    }
188
189    private boolean needAggregate(SQLiteDatabase db, long rawContactId) {
190        final String sql = "SELECT " + RawContacts._ID + " FROM " + Tables.RAW_CONTACTS +
191                " WHERE " + RawContactsColumns.AGGREGATION_NEEDED + "=1" +
192                " AND " + RawContacts._ID + "=?";
193
194        mSelectionArgs1[0] = String.valueOf(rawContactId);
195        final Cursor cursor = db.rawQuery(sql, mSelectionArgs1);
196
197        try {
198            return cursor.getCount() != 0;
199        } finally {
200            cursor.close();
201        }
202    }
203    /**
204     * Find the set of matching raw contacts for given rawContactId. Add all the raw contact
205     * candidates with matching scores > threshold to RawContactMatchingCandidates. Keep doing
206     * this for every raw contact in RawContactMatchingCandidates until is it not changing.
207     */
208    private RawContactMatchingCandidates findRawContactMatchingCandidates(SQLiteDatabase db, long
209            rawContactId, MatchCandidateList candidates, RawContactMatcher matcher) {
210        updateMatchScores(db, rawContactId, candidates, matcher);
211        final RawContactMatchingCandidates matchingCandidates = new RawContactMatchingCandidates(
212                matcher.pickBestMatches());
213        Set<Long> newIds = new HashSet<>();
214        newIds.addAll(matchingCandidates.getRawContactIdSet());
215        // Keep doing the following until no new raw contact candidate is found.
216        while (!newIds.isEmpty()) {
217            if (matchingCandidates.getCount() >= AGGREGATION_CONTACT_SIZE_LIMIT) {
218                return matchingCandidates;
219            }
220            final Set<Long> tmpIdSet = new HashSet<>();
221            for (long rId : newIds) {
222                final RawContactMatcher rMatcher = new RawContactMatcher();
223                updateMatchScores(db, rId, new MatchCandidateList(),
224                        rMatcher);
225                List<MatchScore> newMatches = rMatcher.pickBestMatches();
226                for (MatchScore newMatch : newMatches) {
227                    final long newRawContactId = newMatch.getRawContactId();
228                    if (!matchingCandidates.getRawContactIdSet().contains(newRawContactId)) {
229                        tmpIdSet.add(newRawContactId);
230                        matchingCandidates.add(newMatch);
231                    }
232                }
233            }
234            newIds.clear();
235            newIds.addAll(tmpIdSet);
236        }
237        return matchingCandidates;
238    }
239
240    /**
241     * Find out which mime-types are shared by more than one contacts for {@code rawContactIds}.
242     * Clear the is_super_primary settings for these mime-types.
243     * {@code rawContactIds} should be a comma separated ID list.
244     */
245     private void clearSuperPrimarySetting(SQLiteDatabase db, String rawContactIds) {
246        final String sql =
247                "SELECT " + DataColumns.MIMETYPE_ID + ", count(1) c  FROM " +
248                        Tables.DATA +" WHERE " + Data.IS_SUPER_PRIMARY + " = 1 AND " +
249                        Data.RAW_CONTACT_ID + " IN (" + rawContactIds + ") group by " +
250                        DataColumns.MIMETYPE_ID + " HAVING c > 1";
251
252        // Find out which mime-types exist with is_super_primary=true on more then one contacts.
253        int index = 0;
254        final StringBuilder mimeTypeCondition = new StringBuilder();
255        mimeTypeCondition.append(" AND " + DataColumns.MIMETYPE_ID + " IN (");
256
257        final Cursor c = db.rawQuery(sql, null);
258        try {
259            c.moveToPosition(-1);
260            while (c.moveToNext()) {
261                if (index > 0) {
262                    mimeTypeCondition.append(',');
263                }
264                mimeTypeCondition.append(c.getLong((0)));
265                index++;
266            }
267        } finally {
268            c.close();
269        }
270
271        if (index == 0) {
272            return;
273        }
274
275        // Clear is_super_primary setting for all the mime-types with is_super_primary=true
276        // in both raw contact of rawContactId and raw contacts of contactId
277        String superPrimaryUpdateSql = "UPDATE " + Tables.DATA +
278                " SET " + Data.IS_SUPER_PRIMARY + "=0" +
279                " WHERE " +  Data.RAW_CONTACT_ID +
280                " IN (" + rawContactIds + ")";
281
282        mimeTypeCondition.append(')');
283        superPrimaryUpdateSql += mimeTypeCondition.toString();
284        db.execSQL(superPrimaryUpdateSql);
285    }
286
287    private String buildExceptionMatchingSql(String rawContactIdSet1, String rawContactIdSet2,
288            int aggregationType, boolean countOnly) {
289        final String idPairSelection =  "SELECT " + AggregationExceptions.RAW_CONTACT_ID1 + ", " +
290                AggregationExceptions.RAW_CONTACT_ID2;
291        final String sql =
292                " FROM " + Tables.AGGREGATION_EXCEPTIONS +
293                " WHERE " + AggregationExceptions.RAW_CONTACT_ID1 + " IN (" +
294                        rawContactIdSet1 + ")" +
295                " AND " + AggregationExceptions.RAW_CONTACT_ID2 + " IN (" + rawContactIdSet2 + ")" +
296                " AND " + AggregationExceptions.TYPE + "=" + aggregationType;
297        return (countOnly) ? RawContactMatchingSelectionStatement.SELECT_COUNT + sql :
298                idPairSelection + sql;
299    }
300
301    /**
302     * Re-aggregate rawContact of {@code rawContactId} and all the raw contacts of
303     * {@code matchingCandidates} into connected components. This only happens when a given
304     * raw contacts cannot be joined with its best matching contacts directly.
305     *
306     *  Two raw contacts are considered connected if they share at least one email address, phone
307     *  number or identity. Create new contact for each connected component except the very first
308     *  one that doesn't contain rawContactId of {@code rawContactId}.
309     */
310    private void reAggregateRawContacts(TransactionContext txContext, SQLiteDatabase db,
311            long currentCidForRawContact, long rawContactId, long accountId,
312            long currentContactContentsCount, RawContactMatchingCandidates matchingCandidates) {
313        // Find the connected component based on the aggregation exceptions or
314        // identity/email/phone matching for all the raw contacts of [contactId] and the give
315        // raw contact.
316        final Set<Long> allIds = new HashSet<>();
317        allIds.add(rawContactId);
318        allIds.addAll(matchingCandidates.getRawContactIdSet());
319        final Set<Set<Long>> connectedRawContactSets = findConnectedRawContacts(db, allIds);
320
321        final Map<Long, Long> rawContactsToAccounts = matchingCandidates.getRawContactToAccount();
322        rawContactsToAccounts.put(rawContactId, accountId);
323        ContactAggregatorHelper.mergeComponentsWithDisjointAccounts(connectedRawContactSets,
324                rawContactsToAccounts);
325        breakComponentsByExceptions(db, connectedRawContactSets);
326
327        // Create new contact for each connected component. Use the first reusable contactId if
328        // possible. If no reusable contactId found, create new contact for the connected component.
329        // Update aggregate data for all the contactIds touched by this connected component,
330        for (Set<Long> connectedRawContactIds : connectedRawContactSets) {
331            Long contactId = null;
332            Set<Long> cidsNeedToBeUpdated = new HashSet<>();
333            if (connectedRawContactIds.contains(rawContactId)) {
334                // If there is no other raw contacts aggregated with the given raw contact currently
335                // or all the raw contacts in [currentCidForRawContact] are still in the same
336                // connected component, we might as well reuse it.
337                if (currentCidForRawContact != 0 &&
338                        (currentContactContentsCount == 0) ||
339                        canBeReused(db, currentCidForRawContact, connectedRawContactIds)) {
340                    contactId = currentCidForRawContact;
341                    for (Long connectedRawContactId : connectedRawContactIds) {
342                        Long cid = matchingCandidates.getContactId(connectedRawContactId);
343                        if (cid != null && !cid.equals(contactId)) {
344                            cidsNeedToBeUpdated.add(cid);
345                        }
346                    }
347                } else if (currentCidForRawContact != 0){
348                    cidsNeedToBeUpdated.add(currentCidForRawContact);
349                }
350            } else {
351                boolean foundContactId = false;
352                for (Long connectedRawContactId : connectedRawContactIds) {
353                    Long currentContactId = matchingCandidates.getContactId(connectedRawContactId);
354                    if (!foundContactId && currentContactId != null &&
355                            canBeReused(db, currentContactId, connectedRawContactIds)) {
356                        contactId = currentContactId;
357                        foundContactId = true;
358                    } else {
359                        cidsNeedToBeUpdated.add(currentContactId);
360                    }
361                }
362            }
363            final String connectedRids = TextUtils.join(",", connectedRawContactIds);
364            clearSuperPrimarySetting(db, connectedRids);
365            createContactForRawContacts(db, txContext, connectedRawContactIds, contactId);
366            // re-aggregate
367            if (VERBOSE_LOGGING) {
368                Log.v(TAG, "Aggregating rids=" + connectedRawContactIds);
369            }
370            markAggregated(db, connectedRids);
371
372            for (Long cid : cidsNeedToBeUpdated) {
373                long currentRcCount = 0;
374                if (cid != 0) {
375                    mRawContactCountQuery.bindLong(1, cid);
376                    mRawContactCountQuery.bindLong(2, 0);
377                    currentRcCount = mRawContactCountQuery.simpleQueryForLong();
378                }
379
380                if (currentRcCount == 0) {
381                    // Delete a contact if it doesn't contain anything
382                    ContactsTableUtil.deleteContact(db, cid);
383                    mAggregatedPresenceDelete.bindLong(1, cid);
384                    mAggregatedPresenceDelete.execute();
385                } else {
386                    updateAggregateData(txContext, cid);
387                }
388            }
389        }
390    }
391
392    /**
393     * Check if contactId can be reused as the contact Id for new aggregation of all the
394     * connectedRawContactIds. If connectedRawContactIds set contains all the raw contacts
395     * currently aggregated under contactId, return true; Otherwise, return false.
396     */
397    private boolean canBeReused(SQLiteDatabase db, Long contactId,
398            Set<Long> connectedRawContactIds) {
399        final String sql = "SELECT " + RawContactsColumns.CONCRETE_ID + " FROM " +
400                Tables.RAW_CONTACTS + " WHERE " + RawContacts.CONTACT_ID + "=? AND " +
401                RawContacts.DELETED + "=0";
402        mSelectionArgs1[0] = String.valueOf(contactId);
403        final Cursor cursor = db.rawQuery(sql, mSelectionArgs1);
404        try {
405            cursor.moveToPosition(-1);
406            while (cursor.moveToNext()) {
407                if (!connectedRawContactIds.contains(cursor.getLong(0))) {
408                    return false;
409                }
410            }
411        } finally {
412            cursor.close();
413        }
414        return true;
415    }
416
417    /**
418     * Separate all the raw_contacts which has "SEPARATE" aggregation exception to another
419     * raw_contacts in the same component.
420     */
421    private void breakComponentsByExceptions(SQLiteDatabase db,
422            Set<Set<Long>> connectedRawContacts) {
423        final Set<Set<Long>> tmpSets = new HashSet<>(connectedRawContacts);
424        for (Set<Long> component : tmpSets) {
425            final String rawContacts = TextUtils.join(",", component);
426            // If "SEPARATE" exception is found inside an connected component [component],
427            // remove the [component] from [connectedRawContacts], and create a new connected
428            // component for each raw contact of [component] and add to [connectedRawContacts].
429            if (isFirstColumnGreaterThanZero(db, buildExceptionMatchingSql(rawContacts, rawContacts,
430                    AggregationExceptions.TYPE_KEEP_SEPARATE, /* countOnly =*/true))) {
431                connectedRawContacts.remove(component);
432                for (Long rId : component) {
433                    final Set<Long> s= new HashSet<>();
434                    s.add(rId);
435                    connectedRawContacts.add(s);
436                }
437            }
438        }
439    }
440
441    /**
442     * Ensures that automatic aggregation rules are followed after a contact
443     * becomes visible or invisible. Specifically, consider this case: there are
444     * three contacts named Foo. Two of them come from account A1 and one comes
445     * from account A2. The aggregation rules say that in this case none of the
446     * three Foo's should be aggregated: two of them are in the same account, so
447     * they don't get aggregated; the third has two affinities, so it does not
448     * join either of them.
449     * <p>
450     * Consider what happens if one of the "Foo"s from account A1 becomes
451     * invisible. Nothing stands in the way of aggregating the other two
452     * anymore, so they should get joined.
453     * <p>
454     * What if the invisible "Foo" becomes visible after that? We should split the
455     * aggregate between the other two.
456     */
457    public void updateAggregationAfterVisibilityChange(long contactId) {
458        SQLiteDatabase db = mDbHelper.getWritableDatabase();
459        boolean visible = mDbHelper.isContactInDefaultDirectory(db, contactId);
460        if (visible) {
461            markContactForAggregation(db, contactId);
462        } else {
463            // Find all contacts that _could be_ aggregated with this one and
464            // rerun aggregation for all of them
465            mSelectionArgs1[0] = String.valueOf(contactId);
466            Cursor cursor = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
467                    RawContactIdQuery.SELECTION, mSelectionArgs1, null, null, null);
468            try {
469                while (cursor.moveToNext()) {
470                    long rawContactId = cursor.getLong(RawContactIdQuery.RAW_CONTACT_ID);
471                    mMatcher.clear();
472
473                    updateMatchScoresBasedOnIdentityMatch(db, rawContactId, mMatcher);
474                    updateMatchScoresBasedOnNameMatches(db, rawContactId, mMatcher);
475                    List<MatchScore> bestMatches =
476                            mMatcher.pickBestMatches(SCORE_THRESHOLD_PRIMARY);
477                    for (MatchScore matchScore : bestMatches) {
478                        markContactForAggregation(db, matchScore.getContactId());
479                    }
480
481                    mMatcher.clear();
482                    updateMatchScoresBasedOnEmailMatches(db, rawContactId, mMatcher);
483                    updateMatchScoresBasedOnPhoneMatches(db, rawContactId, mMatcher);
484                    bestMatches =
485                            mMatcher.pickBestMatches(SCORE_THRESHOLD_SECONDARY);
486                    for (MatchScore matchScore : bestMatches) {
487                        markContactForAggregation(db, matchScore.getContactId());
488                    }
489                }
490            } finally {
491                cursor.close();
492            }
493        }
494    }
495
496    /**
497     * Computes match scores based on exceptions entered by the user: always match and never match.
498     */
499    private void updateMatchScoresBasedOnExceptions(SQLiteDatabase db, long rawContactId,
500            RawContactMatcher matcher) {
501        if (!mAggregationExceptionIdsValid) {
502            prefetchAggregationExceptionIds(db);
503        }
504
505        // If there are no aggregation exceptions involving this raw contact, there is no need to
506        // run a query and we can just return -1, which stands for "nothing found"
507        if (!mAggregationExceptionIds.contains(rawContactId)) {
508            return;
509        }
510
511        final Cursor c = db.query(AggregateExceptionQuery.TABLE,
512                AggregateExceptionQuery.COLUMNS,
513                AggregationExceptions.RAW_CONTACT_ID1 + "=" + rawContactId
514                        + " OR " + AggregationExceptions.RAW_CONTACT_ID2 + "=" + rawContactId,
515                null, null, null, null);
516
517        try {
518            while (c.moveToNext()) {
519                int type = c.getInt(AggregateExceptionQuery.TYPE);
520                long rawContactId1 = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1);
521                long contactId = -1;
522                long rId = -1;
523                long accountId = -1;
524                if (rawContactId == rawContactId1) {
525                    if (!c.isNull(AggregateExceptionQuery.RAW_CONTACT_ID2)) {
526                        rId = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID2);
527                        contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID2);
528                        accountId = c.getLong(AggregateExceptionQuery.ACCOUNT_ID2);
529                    }
530                } else {
531                    if (!c.isNull(AggregateExceptionQuery.RAW_CONTACT_ID1)) {
532                        rId = c.getLong(AggregateExceptionQuery.RAW_CONTACT_ID1);
533                        contactId = c.getLong(AggregateExceptionQuery.CONTACT_ID1);
534                        accountId = c.getLong(AggregateExceptionQuery.ACCOUNT_ID1);
535                    }
536                }
537                if (rId != -1) {
538                    if (type == AggregationExceptions.TYPE_KEEP_TOGETHER) {
539                        matcher.keepIn(rId, contactId, accountId);
540                    } else {
541                        matcher.keepOut(rId, contactId, accountId);
542                    }
543                }
544            }
545        } finally {
546            c.close();
547        }
548    }
549
550    /**
551     * Finds contacts with exact identity matches to the the specified raw contact.
552     */
553    private void updateMatchScoresBasedOnIdentityMatch(SQLiteDatabase db, long rawContactId,
554            RawContactMatcher matcher) {
555        mSelectionArgs2[0] = String.valueOf(rawContactId);
556        mSelectionArgs2[1] = String.valueOf(mMimeTypeIdIdentity);
557        Cursor c = db.query(IdentityLookupMatchQuery.TABLE, IdentityLookupMatchQuery.COLUMNS,
558                IdentityLookupMatchQuery.SELECTION,
559                mSelectionArgs2, RawContacts.CONTACT_ID, null, null);
560        try {
561            while (c.moveToNext()) {
562                final long rId = c.getLong(IdentityLookupMatchQuery.RAW_CONTACT_ID);
563                if (rId == rawContactId) {
564                    continue;
565                }
566                final long contactId = c.getLong(IdentityLookupMatchQuery.CONTACT_ID);
567                final long accountId = c.getLong(IdentityLookupMatchQuery.ACCOUNT_ID);
568                matcher.matchIdentity(rId, contactId, accountId);
569            }
570        } finally {
571            c.close();
572        }
573    }
574
575    /**
576     * Finds contacts with names matching the name of the specified raw contact.
577     */
578    private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, long rawContactId,
579            RawContactMatcher matcher) {
580        mSelectionArgs1[0] = String.valueOf(rawContactId);
581        Cursor c = db.query(NameLookupMatchQuery.TABLE, NameLookupMatchQuery.COLUMNS,
582                NameLookupMatchQuery.SELECTION,
583                mSelectionArgs1, null, null, null, PRIMARY_HIT_LIMIT_STRING);
584        try {
585            while (c.moveToNext()) {
586                long rId =  c.getLong(NameLookupMatchQuery.RAW_CONTACT_ID);
587                if (rId == rawContactId) {
588                    continue;
589                }
590                long contactId = c.getLong(NameLookupMatchQuery.CONTACT_ID);
591                long accountId = c.getLong(NameLookupMatchQuery.ACCOUNT_ID);
592                String name = c.getString(NameLookupMatchQuery.NAME);
593                int nameTypeA = c.getInt(NameLookupMatchQuery.NAME_TYPE_A);
594                int nameTypeB = c.getInt(NameLookupMatchQuery.NAME_TYPE_B);
595                matcher.matchName(rId, contactId, accountId, nameTypeA, name,
596                        nameTypeB, name, RawContactMatcher.MATCHING_ALGORITHM_EXACT);
597                if (nameTypeA == NameLookupType.NICKNAME &&
598                        nameTypeB == NameLookupType.NICKNAME) {
599                    matcher.updateScoreWithNicknameMatch(rId, contactId, accountId);
600                }
601            }
602        } finally {
603            c.close();
604        }
605    }
606
607    private void updateMatchScoresBasedOnEmailMatches(SQLiteDatabase db, long rawContactId,
608            RawContactMatcher matcher) {
609        mSelectionArgs2[0] = String.valueOf(rawContactId);
610        mSelectionArgs2[1] = String.valueOf(mMimeTypeIdEmail);
611        Cursor c = db.query(EmailLookupQuery.TABLE, EmailLookupQuery.COLUMNS,
612                EmailLookupQuery.SELECTION,
613                mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
614        try {
615            while (c.moveToNext()) {
616                long rId = c.getLong(EmailLookupQuery.RAW_CONTACT_ID);
617                if (rId == rawContactId) {
618                    continue;
619                }
620                long contactId = c.getLong(EmailLookupQuery.CONTACT_ID);
621                long accountId = c.getLong(EmailLookupQuery.ACCOUNT_ID);
622                matcher.updateScoreWithEmailMatch(rId, contactId, accountId);
623            }
624        } finally {
625            c.close();
626        }
627    }
628
629    /**
630     * Finds contacts with names matching the specified name.
631     */
632    private void updateMatchScoresBasedOnNameMatches(SQLiteDatabase db, String query,
633            MatchCandidateList candidates, RawContactMatcher matcher) {
634        candidates.clear();
635        NameLookupSelectionBuilder builder = new NameLookupSelectionBuilder(
636                mNameSplitter, candidates);
637        builder.insertNameLookup(0, 0, query, FullNameStyle.UNDEFINED);
638        if (builder.isEmpty()) {
639            return;
640        }
641
642        Cursor c = db.query(NameLookupMatchQueryWithParameter.TABLE,
643                NameLookupMatchQueryWithParameter.COLUMNS, builder.getSelection(), null, null, null,
644                null, PRIMARY_HIT_LIMIT_STRING);
645        try {
646            while (c.moveToNext()) {
647                long rId = c.getLong(NameLookupMatchQueryWithParameter.RAW_CONTACT_ID);
648                long contactId = c.getLong(NameLookupMatchQueryWithParameter.CONTACT_ID);
649                long accountId = c.getLong(NameLookupMatchQueryWithParameter.ACCOUNT_ID);
650                String name = c.getString(NameLookupMatchQueryWithParameter.NAME);
651                int nameTypeA = builder.getLookupType(name);
652                int nameTypeB = c.getInt(NameLookupMatchQueryWithParameter.NAME_TYPE);
653                matcher.matchName(rId, contactId, accountId, nameTypeA, name, nameTypeB, name,
654                        RawContactMatcher.MATCHING_ALGORITHM_EXACT);
655                if (nameTypeA == NameLookupType.NICKNAME && nameTypeB == NameLookupType.NICKNAME) {
656                    matcher.updateScoreWithNicknameMatch(rId, contactId, accountId);
657                }
658            }
659        } finally {
660            c.close();
661        }
662    }
663
664    private void updateMatchScoresBasedOnPhoneMatches(SQLiteDatabase db, long rawContactId,
665            RawContactMatcher matcher) {
666        mSelectionArgs2[0] = String.valueOf(rawContactId);
667        mSelectionArgs2[1] = mDbHelper.getUseStrictPhoneNumberComparisonParameter();
668        Cursor c = db.query(PhoneLookupQuery.TABLE, PhoneLookupQuery.COLUMNS,
669                PhoneLookupQuery.SELECTION,
670                mSelectionArgs2, null, null, null, SECONDARY_HIT_LIMIT_STRING);
671        try {
672            while (c.moveToNext()) {
673                long rId = c.getLong(PhoneLookupQuery.RAW_CONTACT_ID);
674                if (rId == rawContactId) {
675                    continue;
676                }
677                long contactId = c.getLong(PhoneLookupQuery.CONTACT_ID);
678                long accountId = c.getLong(PhoneLookupQuery.ACCOUNT_ID);
679                matcher.updateScoreWithPhoneNumberMatch(rId, contactId, accountId);
680            }
681        } finally {
682            c.close();
683        }
684    }
685
686    /**
687     * Loads name lookup rows for approximate name matching and updates match scores based on that
688     * data.
689     */
690    private void lookupApproximateNameMatches(SQLiteDatabase db, MatchCandidateList candidates,
691            RawContactMatcher matcher) {
692        HashSet<String> firstLetters = new HashSet<>();
693        for (int i = 0; i < candidates.mCount; i++) {
694            final NameMatchCandidate candidate = candidates.mList.get(i);
695            if (candidate.mName.length() >= 2) {
696                String firstLetter = candidate.mName.substring(0, 2);
697                if (!firstLetters.contains(firstLetter)) {
698                    firstLetters.add(firstLetter);
699                    final String selection = "(" + NameLookupColumns.NORMALIZED_NAME + " GLOB '"
700                            + firstLetter + "*') AND "
701                            + "(" + NameLookupColumns.NAME_TYPE + " IN("
702                                    + NameLookupType.NAME_COLLATION_KEY + ","
703                                    + NameLookupType.EMAIL_BASED_NICKNAME + ","
704                                    + NameLookupType.NICKNAME + ")) AND "
705                            + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
706                    matchAllCandidates(db, selection, candidates, matcher,
707                            RawContactMatcher.MATCHING_ALGORITHM_APPROXIMATE,
708                            String.valueOf(FIRST_LETTER_SUGGESTION_HIT_LIMIT));
709                }
710            }
711        }
712    }
713
714    private interface ContactNameLookupQuery {
715        String TABLE = Tables.NAME_LOOKUP_JOIN_RAW_CONTACTS;
716
717        String[] COLUMNS = new String[] {
718                RawContacts._ID,
719                RawContacts.CONTACT_ID,
720                RawContactsColumns.ACCOUNT_ID,
721                NameLookupColumns.NORMALIZED_NAME,
722                NameLookupColumns.NAME_TYPE
723        };
724
725        int RAW_CONTACT_ID = 0;
726        int CONTACT_ID = 1;
727        int ACCOUNT_ID = 2;
728        int NORMALIZED_NAME = 3;
729        int NAME_TYPE = 4;
730    }
731
732    /**
733     * Loads all candidate rows from the name lookup table and updates match scores based
734     * on that data.
735     */
736    private void matchAllCandidates(SQLiteDatabase db, String selection,
737            MatchCandidateList candidates, RawContactMatcher matcher, int algorithm, String limit) {
738        final Cursor c = db.query(ContactNameLookupQuery.TABLE, ContactNameLookupQuery.COLUMNS,
739                selection, null, null, null, null, limit);
740
741        try {
742            while (c.moveToNext()) {
743                Long rawContactId = c.getLong(ContactNameLookupQuery.RAW_CONTACT_ID);
744                Long contactId = c.getLong(ContactNameLookupQuery.CONTACT_ID);
745                Long accountId = c.getLong(ContactNameLookupQuery.ACCOUNT_ID);
746                String name = c.getString(ContactNameLookupQuery.NORMALIZED_NAME);
747                int nameType = c.getInt(ContactNameLookupQuery.NAME_TYPE);
748
749                // Note the N^2 complexity of the following fragment. This is not a huge concern
750                // since the number of candidates is very small and in general secondary hits
751                // in the absence of primary hits are rare.
752                for (int i = 0; i < candidates.mCount; i++) {
753                    NameMatchCandidate candidate = candidates.mList.get(i);
754                    matcher.matchName(rawContactId, contactId, accountId, candidate.mLookupType,
755                            candidate.mName, nameType, name, algorithm);
756                }
757            }
758        } finally {
759            c.close();
760        }
761    }
762
763    private interface PhotoFileQuery {
764        final String[] COLUMNS = new String[] {
765                PhotoFiles.HEIGHT,
766                PhotoFiles.WIDTH,
767                PhotoFiles.FILESIZE
768        };
769
770        int HEIGHT = 0;
771        int WIDTH = 1;
772        int FILESIZE = 2;
773    }
774
775    private class PhotoEntry implements Comparable<PhotoEntry> {
776        // Pixel count (width * height) for the image.
777        final int pixelCount;
778
779        // File size (in bytes) of the image.  Not populated if the image is a thumbnail.
780        final int fileSize;
781
782        private PhotoEntry(int pixelCount, int fileSize) {
783            this.pixelCount = pixelCount;
784            this.fileSize = fileSize;
785        }
786
787        @Override
788        public int compareTo(PhotoEntry pe) {
789            if (pe == null) {
790                return -1;
791            }
792            if (pixelCount == pe.pixelCount) {
793                return pe.fileSize - fileSize;
794            } else {
795                return pe.pixelCount - pixelCount;
796            }
797        }
798    }
799
800    /**
801     * Finds contacts with data matches and returns a list of {@link MatchScore}'s in the
802     * descending order of match score.
803     * @param parameters
804     */
805    protected List<MatchScore> findMatchingContacts(final SQLiteDatabase db, long contactId,
806            ArrayList<AggregationSuggestionParameter> parameters) {
807
808        MatchCandidateList candidates = new MatchCandidateList();
809        RawContactMatcher matcher = new RawContactMatcher();
810
811        if (parameters == null || parameters.size() == 0) {
812            final Cursor c = db.query(RawContactIdQuery.TABLE, RawContactIdQuery.COLUMNS,
813                    RawContacts.CONTACT_ID + "=" + contactId, null, null, null, null);
814            try {
815                while (c.moveToNext()) {
816                    long rawContactId = c.getLong(RawContactIdQuery.RAW_CONTACT_ID);
817                    long accountId = c.getLong(RawContactIdQuery.ACCOUNT_ID);
818                    // Don't aggregate a contact with its own raw contacts.
819                    matcher.keepOut(rawContactId, contactId, accountId);
820                    updateMatchScoresForSuggestionsBasedOnDataMatches(db, rawContactId, candidates,
821                            matcher);
822                }
823            } finally {
824                c.close();
825            }
826        } else {
827            updateMatchScoresForSuggestionsBasedOnDataMatches(db, candidates,
828                    matcher, parameters);
829        }
830
831        return matcher.pickBestMatches(SCORE_THRESHOLD_SUGGEST);
832    }
833
834    /**
835     * Computes suggestion scores for contacts that have matching data rows.
836     * Aggregation suggestion doesn't consider aggregation exceptions, but is purely based on the
837     * raw contacts information.
838     */
839    private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
840            long rawContactId, MatchCandidateList candidates, RawContactMatcher matcher) {
841
842        updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
843        updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
844        updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
845        updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
846        loadNameMatchCandidates(db, rawContactId, candidates, false);
847        lookupApproximateNameMatches(db, candidates, matcher);
848    }
849
850    /**
851     * Computes scores for contacts that have matching data rows.
852     */
853    private void updateMatchScores(SQLiteDatabase db, long rawContactId,
854            MatchCandidateList candidates, RawContactMatcher matcher) {
855        //update primary score
856        updateMatchScoresBasedOnExceptions(db, rawContactId, matcher);
857        updateMatchScoresBasedOnNameMatches(db, rawContactId, matcher);
858        // update scores only if the raw contact doesn't have structured name
859        if (rawContactWithoutName(db, rawContactId)) {
860            updateMatchScoresBasedOnIdentityMatch(db, rawContactId, matcher);
861            updateMatchScoresBasedOnEmailMatches(db, rawContactId, matcher);
862            updateMatchScoresBasedOnPhoneMatches(db, rawContactId, matcher);
863            final List<Long> secondaryRawContactIds = matcher.prepareSecondaryMatchCandidates();
864            if (secondaryRawContactIds != null
865                    && secondaryRawContactIds.size() <= SECONDARY_HIT_LIMIT) {
866                updateScoreForCandidatesWithoutName(db, secondaryRawContactIds, matcher);
867            }
868        }
869    }
870
871    private void updateMatchScoresForSuggestionsBasedOnDataMatches(SQLiteDatabase db,
872            MatchCandidateList candidates, RawContactMatcher matcher,
873            ArrayList<AggregationSuggestionParameter> parameters) {
874        for (AggregationSuggestionParameter parameter : parameters) {
875            if (AggregationSuggestions.PARAMETER_MATCH_NAME.equals(parameter.kind)) {
876                updateMatchScoresBasedOnNameMatches(db, parameter.value, candidates, matcher);
877            }
878
879            // TODO: add support for other parameter kinds
880        }
881    }
882
883    private boolean rawContactWithoutName(SQLiteDatabase db, long rawContactId) {
884        String selection = RawContacts._ID + " =" + rawContactId;
885        final Cursor c = db.query(NullNameRawContactsIdsQuery.TABLE,
886                NullNameRawContactsIdsQuery.COLUMNS, selection, null, null, null, null);
887
888        try {
889            if (c.moveToFirst()) {
890                return TextUtils.isEmpty(c.getString(NullNameRawContactsIdsQuery.NAME));
891            }
892        } finally {
893            c.close();
894        }
895        return false;
896    }
897
898    /**
899     * Update scores for matches with secondary data matching but no structured name.
900     */
901    private void updateScoreForCandidatesWithoutName(SQLiteDatabase db,
902            List<Long> secondaryRawContactIds, RawContactMatcher matcher) {
903
904        mSb.setLength(0);
905
906        mSb.append(RawContacts._ID).append(" IN (");
907        for (int i = 0; i < secondaryRawContactIds.size(); i++) {
908            if (i != 0) {
909                mSb.append(",");
910            }
911            mSb.append(secondaryRawContactIds.get(i));
912        }
913        mSb.append( ")");
914        final Cursor c = db.query(NullNameRawContactsIdsQuery.TABLE,
915                NullNameRawContactsIdsQuery.COLUMNS, mSb.toString(), null, null, null, null);
916
917        try {
918            while (c.moveToNext()) {
919                Long rId = c.getLong(NullNameRawContactsIdsQuery.RAW_CONTACT_ID);
920                Long contactId = c.getLong(NullNameRawContactsIdsQuery.CONTACT_ID);
921                Long accountId = c.getLong(NullNameRawContactsIdsQuery.ACCOUNT_ID);
922                String name = c.getString(NullNameRawContactsIdsQuery.NAME);
923                if (TextUtils.isEmpty(name)) {
924                    matcher.matchNoName(rId, contactId, accountId);
925                }
926            }
927        } finally {
928            c.close();
929        }
930    }
931
932    protected interface IdentityLookupMatchQuery {
933        final String TABLE = Tables.DATA + " dataA"
934                + " JOIN " + Tables.DATA + " dataB" +
935                " ON (dataA." + Identity.NAMESPACE + "=dataB." + Identity.NAMESPACE +
936                " AND dataA." + Identity.IDENTITY + "=dataB." + Identity.IDENTITY + ")"
937                + " JOIN " + Tables.RAW_CONTACTS +
938                " ON (dataB." + Data.RAW_CONTACT_ID + " = "
939                + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
940
941        final String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1"
942                + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2"
943                + " AND dataA." + Identity.NAMESPACE + " NOT NULL"
944                + " AND dataA." + Identity.IDENTITY + " NOT NULL"
945                + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2"
946                + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
947
948        final String[] COLUMNS = new String[] {
949                RawContactsColumns.CONCRETE_ID, RawContacts.CONTACT_ID,
950                RawContactsColumns.ACCOUNT_ID
951        };
952
953        int RAW_CONTACT_ID = 0;
954        int CONTACT_ID = 1;
955        int ACCOUNT_ID = 2;
956    }
957
958    protected interface NameLookupMatchQuery {
959        String TABLE = Tables.NAME_LOOKUP + " nameA"
960                + " JOIN " + Tables.NAME_LOOKUP + " nameB" +
961                " ON (" + "nameA." + NameLookupColumns.NORMALIZED_NAME + "="
962                + "nameB." + NameLookupColumns.NORMALIZED_NAME + ")"
963                + " JOIN " + Tables.RAW_CONTACTS +
964                " ON (nameB." + NameLookupColumns.RAW_CONTACT_ID + " = "
965                + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
966
967        String SELECTION = "nameA." + NameLookupColumns.RAW_CONTACT_ID + "=?"
968                + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
969
970        String[] COLUMNS = new String[] {
971                RawContacts._ID,
972                RawContacts.CONTACT_ID,
973                RawContactsColumns.ACCOUNT_ID,
974                "nameA." + NameLookupColumns.NORMALIZED_NAME,
975                "nameA." + NameLookupColumns.NAME_TYPE,
976                "nameB." + NameLookupColumns.NAME_TYPE,
977        };
978
979        int RAW_CONTACT_ID = 0;
980        int CONTACT_ID = 1;
981        int ACCOUNT_ID = 2;
982        int NAME = 3;
983        int NAME_TYPE_A = 4;
984        int NAME_TYPE_B = 5;
985    }
986
987    protected interface EmailLookupQuery {
988        String TABLE = Tables.DATA + " dataA"
989                + " JOIN " + Tables.DATA + " dataB" +
990                " ON dataA." + Email.DATA + "= dataB." + Email.DATA
991                + " JOIN " + Tables.RAW_CONTACTS +
992                " ON (dataB." + Data.RAW_CONTACT_ID + " = "
993                + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
994
995        String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?1"
996                + " AND dataA." + DataColumns.MIMETYPE_ID + "=?2"
997                + " AND dataA." + Email.DATA + " NOT NULL"
998                + " AND dataB." + DataColumns.MIMETYPE_ID + "=?2"
999                + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1000
1001        String[] COLUMNS = new String[] {
1002                Tables.RAW_CONTACTS + "." + RawContacts._ID,
1003                RawContacts.CONTACT_ID,
1004                RawContactsColumns.ACCOUNT_ID
1005        };
1006
1007        int RAW_CONTACT_ID = 0;
1008        int CONTACT_ID = 1;
1009        int ACCOUNT_ID = 2;
1010    }
1011
1012    protected interface PhoneLookupQuery {
1013        String TABLE = Tables.PHONE_LOOKUP + " phoneA"
1014                + " JOIN " + Tables.DATA + " dataA"
1015                + " ON (dataA." + Data._ID + "=phoneA." + PhoneLookupColumns.DATA_ID + ")"
1016                + " JOIN " + Tables.PHONE_LOOKUP + " phoneB"
1017                + " ON (phoneA." + PhoneLookupColumns.MIN_MATCH + "="
1018                + "phoneB." + PhoneLookupColumns.MIN_MATCH + ")"
1019                + " JOIN " + Tables.DATA + " dataB"
1020                + " ON (dataB." + Data._ID + "=phoneB." + PhoneLookupColumns.DATA_ID + ")"
1021                + " JOIN " + Tables.RAW_CONTACTS
1022                + " ON (dataB." + Data.RAW_CONTACT_ID + " = "
1023                + Tables.RAW_CONTACTS + "." + RawContacts._ID + ")";
1024
1025        String SELECTION = "dataA." + Data.RAW_CONTACT_ID + "=?"
1026                + " AND PHONE_NUMBERS_EQUAL(dataA." + Phone.NUMBER + ", "
1027                + "dataB." + Phone.NUMBER + ",?)"
1028                + " AND " + RawContacts.CONTACT_ID + " IN " + Tables.DEFAULT_DIRECTORY;
1029
1030        String[] COLUMNS = new String[] {
1031                Tables.RAW_CONTACTS + "." + RawContacts._ID,
1032                RawContacts.CONTACT_ID,
1033                RawContactsColumns.ACCOUNT_ID
1034        };
1035
1036        int RAW_CONTACT_ID = 0;
1037        int CONTACT_ID = 1;
1038        int ACCOUNT_ID = 2;
1039    }
1040
1041    protected interface NullNameRawContactsIdsQuery {
1042        final String TABLE =  Tables.RAW_CONTACTS + " LEFT OUTER JOIN " +  Tables.NAME_LOOKUP
1043                + " ON "+ RawContacts._ID + " = " + NameLookupColumns.RAW_CONTACT_ID
1044                + " AND " + NameLookupColumns.NAME_TYPE + " = " + NameLookupType.NAME_EXACT;
1045
1046        final String[] COLUMNS = new String[] {
1047                RawContacts._ID, RawContacts.CONTACT_ID, RawContactsColumns.ACCOUNT_ID,
1048                NameLookupColumns.NORMALIZED_NAME};
1049
1050        int RAW_CONTACT_ID = 0;
1051        int CONTACT_ID = 1;
1052        int ACCOUNT_ID = 2;
1053        int NAME = 3;
1054    }
1055}
1056