/*************************************************************************************** * * * Copyright (c) 2015 Timothy Rae <[email protected]> * * Copyright (c) 2016 Mark Carter <[email protected]> * * * * This program is free software; you can redistribute it and/or modify it under * * the terms of the GNU Lesser General Public License as published by the Free Software * * Foundation; either version 3 of the License, or (at your option) any later * * version. * * * * This program is distributed in the hope that it will be useful, but WITHOUT ANY * * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * * PARTICULAR PURPOSE. See the GNU General Public License for more details. * * * * You should have received a copy of the GNU Lesser General Public License along with * * this program. If not, see <http://www.gnu.org/licenses/>. * ****************************************************************************************/ package com.ichi2.anki.api; import android.content.ContentResolver; import android.content.ContentValues; import android.content.Context; import android.content.pm.PackageManager; import android.content.pm.ProviderInfo; import android.database.Cursor; import android.net.Uri; import android.os.Build; import android.os.Process; import android.text.TextUtils; import android.util.SparseArray; import com.ichi2.anki.FlashCardsContract; import com.ichi2.anki.FlashCardsContract.Card; import com.ichi2.anki.FlashCardsContract.CardTemplate; import com.ichi2.anki.FlashCardsContract.Deck; import com.ichi2.anki.FlashCardsContract.Model; import com.ichi2.anki.FlashCardsContract.Note; import com.mmjang.ankihelper.R; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; /** * API which can be used to add and query notes,cards,decks, and models to AnkiDroid * * On Android M (and higher) the #READ_WRITE_PERMISSION is required for all read/write operations. * On earlier SDK levels, the #READ_WRITE_PERMISSION is currently only required for update/delete operations but * this may be extended to all operations at a later date. */ @SuppressWarnings("unused") public final class AddContentApi { private final ContentResolver mResolver; private final Context mContext; public static final String READ_WRITE_PERMISSION = FlashCardsContract.READ_WRITE_PERMISSION; public static final long DEFAULT_DECK_ID = 1L; private static final String TEST_TAG = "PREVIEW_NOTE"; private static final String PROVIDER_SPEC_META_DATA_KEY = "com.ichi2.anki.provider.spec"; private static final int DEFAULT_PROVIDER_SPEC_VALUE = 1; // for when meta-data key does not exist private static final String[] PROJECTION = {Note._ID, Note.FLDS, Note.TAGS}; public AddContentApi(Context context) { mContext = context.getApplicationContext(); mResolver = mContext.getContentResolver(); } /** * Create a new note with specified fields, tags, and model and place it in the specified deck. * No duplicate checking is performed - so the note should be checked beforehand using #findNotesByKeys * @param modelId ID for the model used to add the notes * @param deckId ID for the deck the cards should be stored in (use #DEFAULT_DECK_ID for default deck) * @param fields fields to add to the note. Length should be the same as number of fields in model * @param tags tags to include in the new note * @return note id or null if the note could not be added */ public Long addNote(long modelId, long deckId, String[] fields, Set<String> tags) { Uri noteUri = addNoteInternal(modelId, deckId, fields, tags); if (noteUri == null) { return null; } return Long.parseLong(noteUri.getLastPathSegment()); } private Uri addNoteInternal(long modelId, long deckId, String[] fields, Set<String> tags) { ContentValues values = new ContentValues(); values.put(Note.MID, modelId); values.put(Note.FLDS, Utils.joinFields(fields)); if (tags != null) { values.put(Note.TAGS, Utils.joinTags(tags)); } return addNoteForContentValues(deckId, values); } private Uri addNoteForContentValues(long deckId, ContentValues values) { Uri newNoteUri = null; try { newNoteUri = mResolver.insert(Note.CONTENT_URI, values); }catch (Exception e){ com.mmjang.ankihelper.util.Utils.showMessage(mContext, mContext.getString(R.string.str_check_ankidroid_permisson)); return null; } if (newNoteUri == null) { return null; } // Move cards to specified deck Uri cardsUri = Uri.withAppendedPath(newNoteUri, "cards"); final Cursor cardsCursor = mResolver.query(cardsUri, null, null, null, null); if (cardsCursor == null) { return null; } try { while (cardsCursor.moveToNext()) { String ord = cardsCursor.getString(cardsCursor.getColumnIndex(Card.CARD_ORD)); ContentValues cardValues = new ContentValues(); cardValues.put(Card.DECK_ID, deckId); Uri cardUri = Uri.withAppendedPath(Uri.withAppendedPath(newNoteUri, "cards"), ord); mResolver.update(cardUri, cardValues, null, null); } } finally { cardsCursor.close(); } return newNoteUri; } /** * Create new notes with specified fields, tags and model and place them in the specified deck. * No duplicate checking is performed - so all notes should be checked beforehand using #findNotesByKeys * @param modelId id for the model used to add the notes * @param deckId id for the deck the cards should be stored in (use #DEFAULT_DECK_ID for default deck) * @param fieldsList List of fields arrays (one per note). Array lengths should be same as number of fields in model * @param tagsList List of tags (one per note) (may be null) * @return The number of notes added (<0 means there was a problem) */ public int addNotes(long modelId, long deckId, List<String[]> fieldsList, List<Set<String>> tagsList) { if (tagsList != null && fieldsList.size() != tagsList.size()) { throw new IllegalArgumentException("fieldsList and tagsList different length"); } List<ContentValues> newNoteValuesList = new ArrayList<>(); for (int i = 0; i < fieldsList.size(); i++) { ContentValues values = new ContentValues(); values.put(Note.MID, modelId); values.put(Note.FLDS, Utils.joinFields(fieldsList.get(i))); if (tagsList != null && tagsList.get(i) != null) { values.put(Note.TAGS, Utils.joinTags(tagsList.get(i))); } newNoteValuesList.add(values); } // Add the notes to the content provider and put the new note ids into the result array if (newNoteValuesList.isEmpty()) { return 0; } return getCompat().insertNotes(deckId, newNoteValuesList.toArray(new ContentValues[newNoteValuesList.size()])); } /** * Find all existing notes in the collection which have mid and a duplicate key * @param mid model id * @param key the first field of a note * @return a list of duplicate notes */ public List<NoteInfo> findDuplicateNotes(long mid, String key) { SparseArray<List<NoteInfo>> notes = getCompat().findDuplicateNotes(mid, Collections.singletonList(key)); if (notes.size() == 0) { return Collections.emptyList(); } return notes.valueAt(0); } /** * Find all notes in the collection which have mid and a first field that matches key * Much faster than calling findDuplicateNotes(long, String) when the list of keys is large * @param mid model id * @param keys list of keys * @return a SparseArray with a list of duplicate notes for each key */ public SparseArray<List<NoteInfo>> findDuplicateNotes(long mid, List<String> keys) { return getCompat().findDuplicateNotes(mid, keys); } /** * Get the number of notes that exist for the specified model ID * @param mid id of the model to be used * @return number of notes that exist with that model ID or -1 if there was a problem */ public int getNoteCount(long mid) { Cursor cursor = getCompat().queryNotes(mid); if (cursor == null) { return 0; } try { return cursor.getCount(); } finally { cursor.close(); } } /** * Set the tags for a given note * @param noteId the ID of the note to update * @param tags set of tags * @return true if noteId was found, otherwise false * @throws SecurityException if READ_WRITE_PERMISSION not granted (e.g. due to install order bug) */ public boolean updateNoteTags(long noteId, Set<String> tags) { return updateNote(noteId, null, tags); } /** * Set the fields for a given note * @param noteId the ID of the note to update * @param fields array of fields * @return true if noteId was found, otherwise false * @throws SecurityException if READ_WRITE_PERMISSION not granted (e.g. due to install order bug) */ public boolean updateNoteFields(long noteId, String[] fields) { return updateNote(noteId, fields, null); } /** * Get the contents of a note with known ID * @param noteId the ID of the note to find * @return object containing the contents of note with noteID or null if there was a problem */ public NoteInfo getNote(long noteId) { Uri noteUri = Uri.withAppendedPath(Note.CONTENT_URI, Long.toString(noteId)); Cursor cursor = mResolver.query(noteUri, PROJECTION, null, null, null); if (cursor == null) { return null; } try { if (!cursor.moveToNext()) { return null; } return NoteInfo.buildFromCursor(cursor); } finally { cursor.close(); } } private boolean updateNote(long noteId, String[] fields, Set<String> tags) { Uri.Builder builder = Note.CONTENT_URI.buildUpon(); Uri contentUri = builder.appendPath(Long.toString(noteId)).build(); ContentValues values = new ContentValues(); if (fields != null) { values.put(Note.FLDS, Utils.joinFields(fields)); } if (tags != null) { values.put(Note.TAGS, Utils.joinTags(tags)); } int numRowsUpdated = mResolver.update(contentUri, values, null, null); // provider doesn't check whether fields actually changed, so just returns number of notes with id == noteId return numRowsUpdated > 0; } /** * Get the html that would be generated for the specified note type and field list * @param flds array of field values for the note. Length must be the same as num. fields in mid. * @param mid id for the note type to be used * @return list of front & back pairs for each card which contain the card HTML, or null if there was a problem * @throws SecurityException if READ_WRITE_PERMISSION not granted (e.g. due to install order bug) */ public Map<String, Map<String, String>> previewNewNote(long mid, String[] flds) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M && !hasReadWritePermission()) { // avoid situation where addNote will pass, but deleteNote will fail throw new SecurityException("previewNewNote requires full read-write-permission"); } Uri newNoteUri = addNoteInternal(mid, DEFAULT_DECK_ID, flds, Collections.singleton(TEST_TAG)); // Build map of HTML for each generated card Map<String, Map<String, String>> cards = new HashMap<>(); Uri cardsUri = Uri.withAppendedPath(newNoteUri, "cards"); final Cursor cardsCursor = mResolver.query(cardsUri, null, null, null, null); if (cardsCursor == null) { return null; } try { while (cardsCursor.moveToNext()) { // add question and answer for each card to map final String n = cardsCursor.getString(cardsCursor.getColumnIndex(Card.CARD_NAME)); final String q = cardsCursor.getString(cardsCursor.getColumnIndex(Card.QUESTION)); final String a = cardsCursor.getString(cardsCursor.getColumnIndex(Card.ANSWER)); Map<String, String> html = new HashMap<>(); html.put("q", q); html.put("a", a); cards.put(n, html); } } finally { cardsCursor.close(); } // Delete the note mResolver.delete(newNoteUri, null, null); return cards; } /** * Insert a new basic front/back model with two fields and one card * @param name name of the model * @return the mid of the model which was created, or null if it could not be created */ public Long addNewBasicModel(String name) { return addNewCustomModel(name, BasicModel.FIELDS, BasicModel.CARD_NAMES, BasicModel.QFMT, BasicModel.AFMT, null, null, null); } /** * Insert a new basic front/back model with two fields and TWO cards * The first card goes from front->back, and the second goes from back->pront * @param name name of the model * @return the mid of the model which was created, or null if it could not be created */ public Long addNewBasic2Model(String name) { return addNewCustomModel(name, Basic2Model.FIELDS, Basic2Model.CARD_NAMES, Basic2Model.QFMT, Basic2Model.AFMT, null, null, null); } /** * Insert a new model into AnkiDroid. * See the <a href="http://ankisrs.net/docs/manual.html#cards-and-templates">Anki Desktop Manual</a> for more help * @param name: name of model * @param fields: array of field names * @param cards: array of names for the card templates * @param qfmt: array of formatting strings for the question side of each template in cards * @param afmt: array of formatting strings for the answer side of each template in cards * @param css: css styling information to be shared across all of the templates. Use null for default CSS. * @param did: default deck to add cards to when using this model. Use null or #DEFAULT_DECK_ID for default deck. * @param sortf: index of field to be used for sorting. Use null for unspecified (unsupported in provider spec v1) * @return the mid of the model which was created, or null if it could not be created */ public Long addNewCustomModel(String name, String[] fields, String[] cards, String[] qfmt, String[] afmt, String css, Long did, Integer sortf) { // Check that size of arrays are consistent if (qfmt.length != cards.length || afmt.length != cards.length) { throw new IllegalArgumentException("cards, qfmt, and afmt arrays must all be same length"); } // Create the model using dummy templates ContentValues values = new ContentValues(); values.put(Model.NAME, name); values.put(Model.FIELD_NAMES, Utils.joinFields(fields)); values.put(Model.NUM_CARDS, cards.length); values.put(Model.CSS, css); values.put(Model.DECK_ID, did); values.put(Model.SORT_FIELD_INDEX, sortf); Uri modelUri = mResolver.insert(Model.CONTENT_URI, values); if (modelUri == null) { return null; } // Set the remaining template parameters Uri templatesUri = Uri.withAppendedPath(modelUri, "templates"); for (int i = 0; i < cards.length; i++) { Uri uri = Uri.withAppendedPath(templatesUri, Integer.toString(i)); values = new ContentValues(); values.put(CardTemplate.NAME, cards[i]); values.put(CardTemplate.QUESTION_FORMAT, qfmt[i]); values.put(CardTemplate.ANSWER_FORMAT, afmt[i]); values.put(CardTemplate.ANSWER_FORMAT, afmt[i]); mResolver.update(uri, values, null, null); } return Long.parseLong(modelUri.getLastPathSegment()); } /** * Get the ID for the note type / model which is currently in use * @return id for current model, or <0 if there was a problem */ public long getCurrentModelId() { // Get the current model Uri uri = Uri.withAppendedPath(Model.CONTENT_URI, Model.CURRENT_MODEL_ID); final Cursor singleModelCursor = mResolver.query(uri, null, null, null, null); if (singleModelCursor == null) { return -1L; } long modelId; try { singleModelCursor.moveToFirst(); modelId = singleModelCursor.getLong(singleModelCursor.getColumnIndex(Model._ID)); } finally { singleModelCursor.close(); } return modelId; } /** * Get the field names belonging to specified model * @param modelId the ID of the model to use * @return the names of all the fields, or null if the model doesn't exist or there was some other problem */ public String[] getFieldList(long modelId) { // Get the current model Uri uri = Uri.withAppendedPath(Model.CONTENT_URI, Long.toString(modelId)); final Cursor modelCursor = mResolver.query(uri, null, null, null, null); if (modelCursor == null) { return null; } String[] splitFlds = null; try { if (modelCursor.moveToNext()) { String flds = modelCursor.getString(modelCursor.getColumnIndex(Model.FIELD_NAMES)); splitFlds = Utils.splitFields(flds); } } finally { modelCursor.close(); } return splitFlds; } /** * Get a map of all model ids and names * @return map of (id, name) pairs */ public Map<Long, String> getModelList() { return getModelList(1); } /** * Get a map of all model ids and names with number of fields larger than minNumFields * @param minNumFields minimum number of fields to consider the model for inclusion * @return map of (id, name) pairs or null if there was a problem */ public Map<Long, String> getModelList(int minNumFields) { // Get the current model final Cursor allModelsCursor = mResolver.query(Model.CONTENT_URI, null, null, null, null); if (allModelsCursor == null) { return null; } Map<Long, String> models = new HashMap<>(); try { while (allModelsCursor.moveToNext()) { long modelId = allModelsCursor.getLong(allModelsCursor.getColumnIndex(Model._ID)); String name = allModelsCursor.getString(allModelsCursor.getColumnIndex(Model.NAME)); String flds = allModelsCursor.getString( allModelsCursor.getColumnIndex(Model.FIELD_NAMES)); int numFlds = Utils.splitFields(flds).length; if (numFlds >= minNumFields) { models.put(modelId, name); } } } finally { allModelsCursor.close(); } return models; } /** * Get the name of the model which has given ID * @param mid id of model * @return the name of the model, or null if no model was found */ public String getModelName(Long mid) { if (mid != null && mid >= 0) { Map<Long, String> modelList = getModelList(); for (Map.Entry<Long, String> entry : modelList.entrySet()) { if (entry.getKey().equals(mid)) { return entry.getValue(); } } } return null; } /** * Create a new deck with specified name and save the reference to SharedPreferences for later * @param deckName name of the deck to add * @return id of the added deck, or null if the deck was not added */ public Long addNewDeck(String deckName) { // Create a new note ContentValues values = new ContentValues(); values.put(Deck.DECK_NAME, deckName); Uri newDeckUri = mResolver.insert(Deck.CONTENT_ALL_URI, values); if (newDeckUri != null) { return Long.parseLong(newDeckUri.getLastPathSegment()); } else { return null; } } /** * Get the name of the selected deck * @return deck name or null if there was a problem */ public String getSelectedDeckName() { final Cursor selectedDeckCursor = mResolver.query(Deck.CONTENT_SELECTED_URI, null, null, null, null); if (selectedDeckCursor == null) { return null; } String name = null; try { if (selectedDeckCursor.moveToNext()) { name=selectedDeckCursor.getString(selectedDeckCursor.getColumnIndex(Deck.DECK_NAME)); } } finally { selectedDeckCursor.close(); } return name; } /** * Get a list of all the deck id / name pairs * @return Map of (id, name) pairs, or null if there was a problem */ public Map<Long, String> getDeckList() { // Get the current model final Cursor allDecksCursor = mResolver.query(Deck.CONTENT_ALL_URI, null, null, null, null); if (allDecksCursor == null) { return null; } Map<Long, String> decks = new HashMap<>(); try { while (allDecksCursor.moveToNext()) { long deckId = allDecksCursor.getLong(allDecksCursor.getColumnIndex(Deck.DECK_ID)); String name =allDecksCursor.getString(allDecksCursor.getColumnIndex(Deck.DECK_NAME)); decks.put(deckId, name); } } finally { allDecksCursor.close(); } return decks; } /** * Get the name of the deck which has given ID * @param did ID of deck * @return the name of the deck, or null if no deck was found */ public String getDeckName(Long did) { Map<Long, String> deckList = getDeckList(); if (did != null && did >= 0 && deckList != null) { for (Map.Entry<Long, String> entry : deckList.entrySet()) { if (entry.getKey().equals(did)) { return entry.getValue(); } } } return null; } /** * Get the AnkiDroid package name that the API will communicate with. * This can be used to check that a supported version of AnkiDroid is installed, * or to get the application label and icon, etc. * @param context a Context that can be used to get the PackageManager * @return packageId of AnkiDroid if a supported version is not installed, otherwise null */ public static String getAnkiDroidPackageName(Context context) { PackageManager manager = context.getPackageManager(); ProviderInfo pi = manager.resolveContentProvider(FlashCardsContract.AUTHORITY, 0); if (pi != null) { return pi.packageName; } else { return null; } } /** * The API spec version of the installed AnkiDroid app. This is not the same as the AnkiDroid app version code. * * SPEC VERSION 1: (AnkiDroid 2.5) * #addNotes is very slow for large numbers of notes * #findDuplicateNotes is very slow for large numbers of keys * #addNewCustomModel is not persisted properly * #addNewCustomModel does not support #sortf argument * * SPEC VERSION 2: (AnkiDroid 2.6) * * @return the spec version number or -1 if AnkiDroid is not installed. */ public int getApiHostSpecVersion() { // PackageManager#resolveContentProvider docs suggest flags should be 0 (but that gives null metadata) // GET_META_DATA seems to work anyway ProviderInfo info = mContext.getPackageManager().resolveContentProvider(FlashCardsContract.AUTHORITY, PackageManager.GET_META_DATA); if (info == null) { return -1; } if (info.metaData != null && info.metaData.containsKey(PROVIDER_SPEC_META_DATA_KEY)) { return info.metaData.getInt(PROVIDER_SPEC_META_DATA_KEY); } else { return DEFAULT_PROVIDER_SPEC_VALUE; } } private boolean hasReadWritePermission() { return mContext.checkPermission(READ_WRITE_PERMISSION, Process.myPid(), Process.myUid()) == PackageManager.PERMISSION_GRANTED; } /** * Best not to store this in case the user updates AnkiDroid app while client app is staying alive */ private Compat getCompat() { return getApiHostSpecVersion() < 2 ? new CompatV1() : new CompatV2(); } private interface Compat { /** * Query all notes for a given model * @param modelId the model ID to limit query to * @return a cursor with all notes matching modelId */ Cursor queryNotes(long modelId); /** * Add new notes to the AnkiDroid content provider in bulk. * @param deckId the deck ID to put the cards in * @param valuesArr the content values ready for bulk insertion into the content provider * @return the number of successful entries */ int insertNotes(long deckId, ContentValues[] valuesArr); /** * For each key, look for an existing note that has matching first field * @param modelId the model ID to limit the search to * @param keys list of keys for each note * @return array with a list of NoteInfo objects for each key if duplicates exist */ SparseArray<List<NoteInfo>> findDuplicateNotes(long modelId, List<String> keys); } private class CompatV1 implements Compat { @Override public Cursor queryNotes(long modelId) { String modelName = getModelName(modelId); if (modelName == null) { return null; } String queryFormat = String.format("note:\"%s\"", modelName); return mResolver.query(Note.CONTENT_URI, PROJECTION, queryFormat, null, null); } @Override public int insertNotes(long deckId, ContentValues[] valuesArr) { int result = 0; for (ContentValues values : valuesArr) { Uri noteUri = addNoteForContentValues(deckId, values); if (noteUri != null) { result++; } } return result; } @Override public SparseArray<List<NoteInfo>> findDuplicateNotes(long modelId, List<String> keys) { // Content provider spec v1 does not support direct querying of the notes table, so use Anki browser syntax String modelName = getModelName(modelId); String[] modelFieldList = getFieldList(modelId); if (modelName == null || modelFieldList == null) { return null; } SparseArray<List<NoteInfo>> duplicates = new SparseArray<>(); // Loop through each item in fieldsArray looking for an existing note, and add it to the duplicates array String queryFormat = String.format("%s:\"%%s\" note:\"%s\"", modelFieldList[0], modelName); for (int outputPos = 0; outputPos < keys.size(); outputPos++) { String selection = String.format(queryFormat, keys.get(outputPos)); Cursor cursor = mResolver.query(Note.CONTENT_URI, PROJECTION, selection, null, null); if (cursor == null) { continue; } try { while (cursor.moveToNext()) { addNoteToDuplicatesArray(NoteInfo.buildFromCursor(cursor), duplicates, outputPos); } } finally { cursor.close(); } } return duplicates; } /** Add a NoteInfo object to the given duplicates SparseArray at the specified position */ protected void addNoteToDuplicatesArray(NoteInfo note, SparseArray<List<NoteInfo>> duplicates, int position) { int sparseArrayIndex = duplicates.indexOfKey(position); if (sparseArrayIndex < 0) { // No existing NoteInfo objects mapping to same key as the current note so add a new List List<NoteInfo> duplicatesForKey = new ArrayList<>(); duplicatesForKey.add(note); duplicates.put(position, duplicatesForKey); } else { // Append note to existing list of duplicates for key duplicates.valueAt(sparseArrayIndex).add(note); } } } private class CompatV2 extends CompatV1 { @Override public Cursor queryNotes(long modelId) { return mResolver.query(Note.CONTENT_URI_V2, PROJECTION, String.format(Locale.US, "%s=%d", Note.MID, modelId), null, null); } @Override public int insertNotes(long deckId, ContentValues[] valuesArr) { Uri.Builder builder = Note.CONTENT_URI.buildUpon(); builder.appendQueryParameter(Note.DECK_ID_QUERY_PARAM, String.valueOf(deckId)); return mResolver.bulkInsert(builder.build(), valuesArr); } @Override public SparseArray<List<NoteInfo>> findDuplicateNotes(long modelId, List<String> keys) { // Build set of checksums and a HashMap from the key (first field) back to the original index in fieldsArray Set<Long> csums = new HashSet<>(keys.size()); Map<String, List<Integer>> keyToIndexesMap = new HashMap<>(keys.size()); for (int i = 0; i < keys.size(); i++) { String key = keys.get(i); csums.add(Utils.fieldChecksum(key)); if (!keyToIndexesMap.containsKey(key)) { // Use a list as some keys could potentially be duplicated keyToIndexesMap.put(key, new ArrayList<Integer>()); } keyToIndexesMap.get(key).add(i); } // Query for notes that have specified model and checksum of first field matches String sel = String.format(Locale.US, "%s=%d and %s in (%s)", Note.MID, modelId, Note.CSUM, TextUtils.join(",", csums)); Cursor notesTableCursor = mResolver.query(Note.CONTENT_URI_V2, PROJECTION, sel, null, null); if (notesTableCursor == null) { return null; } // Loop through each note in the cursor, building the result array of duplicates SparseArray<List<NoteInfo>> duplicates = new SparseArray<>(); try { while (notesTableCursor.moveToNext()) { NoteInfo note = NoteInfo.buildFromCursor(notesTableCursor); if (note == null) { continue; } if (keyToIndexesMap.containsKey(note.getKey())) { // skip notes that match csum but not key // Add copy of note to EVERY position in duplicates array corresponding to the current key List<Integer> outputPos = keyToIndexesMap.get(note.getKey()); for (int i = 0; i < outputPos.size(); i++) { addNoteToDuplicatesArray(i > 0 ? new NoteInfo(note) : note, duplicates, outputPos.get(i)); } } } } finally { notesTableCursor.close(); } return duplicates; } } }