diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08e1ad7..af7732f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,9 +14,18 @@ > system-level interaction with the user. --> + + + + + + + + + = Build.VERSION_CODES.TIRAMISU) { + providerInfo = pm.resolveContentProvider( + authority, + PackageManager.ComponentInfoFlags.of(0L) + ); + } else { + providerInfo = pm.resolveContentProvider(authority, 0); + } + + if (providerInfo != null) { + // Verify the ContentProvider is actually accessible by trying a test query + // This is important for Android 11+ where package visibility restrictions + // may prevent access even if the provider is found + Uri testUri = Uri.parse("content://" + authority + "/decks"); + try { + Cursor testCursor = resolver.query(testUri, new String[]{"_id"}, null, null, null); + if (testCursor != null) { + testCursor.close(); + Log.d(TAG, "Found accessible AnkiDroid ContentProvider with authority: " + authority + ", package: " + providerInfo.packageName); + cachedAuthority = authority; + return cachedAuthority; + } else { + Log.d(TAG, "Authority " + authority + " found but test query returned null cursor"); + } + } catch (IllegalArgumentException e) { + Log.d(TAG, "Authority " + authority + " found but URI not supported: " + e.getMessage()); + // Continue to next authority + } catch (SecurityException e) { + Log.d(TAG, "Authority " + authority + " found but access denied: " + e.getMessage()); + // Continue to next authority + } catch (Exception e) { + Log.d(TAG, "Authority " + authority + " found but test query failed: " + e.getMessage()); + // Continue to next authority + } + } + } catch (Exception e) { + Log.d(TAG, "Error checking authority " + authority + ": " + e.getMessage()); + } + } + + Log.e(TAG, "Could not find any accessible AnkiDroid ContentProvider authority"); + return null; + } + + /** + * Find cards using a search query. + * Uses Ankidroid's ContentProvider cards endpoint with Collection.findCards() + * + * @param query The search query (same syntax as Anki browser) + * @return List of card IDs matching the query + */ + public List findCards(String query) { + List cardIds = new ArrayList<>(); + + // Find the correct authority (release or debug) + String authority = findAnkiDroidAuthority(); + if (authority == null) { + Log.e(TAG, "Could not find AnkiDroid ContentProvider authority"); + return cardIds; + } + + // Use the new cards ContentProvider endpoint + // The selection parameter contains the search query + // Construct URI manually with the correct authority: content://AUTHORITY/cards + Uri authorityUri = Uri.parse("content://" + authority); + Uri cardsUri = Uri.withAppendedPath(authorityUri, "cards"); + Log.d(TAG, "Querying cards with URI: " + cardsUri); + + try { + Cursor cursor = resolver.query( + cardsUri, + new String[]{"_id"}, + query, + null, + null + ); + + if (cursor != null) { + try { + int idColumnIndex = cursor.getColumnIndexOrThrow("_id"); + while (cursor.moveToNext()) { + long cardId = cursor.getLong(idColumnIndex); + cardIds.add(cardId); + } + Log.d(TAG, "Successfully queried cards, found " + cardIds.size() + " card IDs"); + } finally { + cursor.close(); + } + } else { + Log.w(TAG, "Cursor is null for URI: " + cardsUri + ". ContentProvider may not be accessible."); + // Clear cache to force re-detection next time + cachedAuthority = null; + } + } catch (IllegalArgumentException e) { + Log.e(TAG, "URI not supported: " + cardsUri + ". Error: " + e.getMessage()); + // Clear cache to force re-detection next time + cachedAuthority = null; + } catch (SecurityException e) { + Log.e(TAG, "Security exception accessing ContentProvider: " + cardsUri + ". Error: " + e.getMessage()); + // Clear cache to force re-detection next time + cachedAuthority = null; + } catch (Exception e) { + Log.e(TAG, "Error querying cards: " + e.getMessage(), e); + // Clear cache to force re-detection next time + cachedAuthority = null; + } + + return cardIds; + } + + /** + * Get card information for a list of card IDs. + * Returns data in Anki-Connect format for compatibility. + * Optimized for large collections by batching note lookups. + * + * @param cardIds List of card IDs + * @return List of card info maps (serialized as Map for JSON compatibility) + */ + public List> cardsInfo(List cardIds) throws Exception { + List> cardsInfoList = new ArrayList<>(); + + if (cardIds == null || cardIds.isEmpty()) { + return cardsInfoList; + } + + // Find the correct authority (release or debug) + String authority = findAnkiDroidAuthority(); + if (authority == null) { + Log.e(TAG, "Could not find AnkiDroid ContentProvider authority"); + return cardsInfoList; + } + + // Request all card columns including scheduling fields + String[] cardProjection = { + "_id", // Card ID (using string literal since constant not available) + FlashCardsContract.Card.NOTE_ID, + FlashCardsContract.Card.CARD_ORD, + FlashCardsContract.Card.CARD_NAME, + FlashCardsContract.Card.DECK_ID, + FlashCardsContract.Card.QUESTION, + FlashCardsContract.Card.ANSWER, + "due", // Scheduling fields + "interval", + "ease_factor", + "reviews" + }; + + // Step 1: Collect all card data and note IDs + // Use a map to store card data by cardId, and collect unique note IDs + Map> cardDataMap = new HashMap<>(); + Set noteIdsSet = new HashSet<>(); + boolean verboseLogging = cardIds.size() <= 50; // Only log verbosely for small batches + + if (verboseLogging) { + Log.d(TAG, "Processing " + cardIds.size() + " cards"); + } else { + Log.d(TAG, "Processing " + cardIds.size() + " cards (batch mode, reduced logging)"); + } + + // Process cards in batches to avoid memory issues + int batchSize = 100; + for (int i = 0; i < cardIds.size(); i += batchSize) { + int end = Math.min(i + batchSize, cardIds.size()); + List batch = cardIds.subList(i, end); + + for (Long cardId : batch) { + try { + // Construct URI manually with the correct authority: content://AUTHORITY/cards/ + Uri authorityUri = Uri.parse("content://" + authority); + Uri cardsUri = Uri.withAppendedPath(authorityUri, "cards"); + Uri cardUri = Uri.withAppendedPath(cardsUri, Long.toString(cardId)); + + if (verboseLogging) { + Log.d(TAG, "Querying card info with URI: " + cardUri); + } + + Cursor cursor = resolver.query(cardUri, cardProjection, null, null, null); + + if (cursor != null && cursor.moveToFirst()) { + try { + // Get card data + int noteIdIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Card.NOTE_ID); + long noteId = cursor.getLong(noteIdIdx); + + // Get card scheduling info from cursor + int dueIdx = cursor.getColumnIndex("due"); + int intervalIdx = cursor.getColumnIndex("interval"); + int easeFactorIdx = cursor.getColumnIndex("ease_factor"); + int reviewsIdx = cursor.getColumnIndex("reviews"); + + long due = dueIdx >= 0 ? cursor.getLong(dueIdx) : 0; + long interval = intervalIdx >= 0 ? cursor.getLong(intervalIdx) : 0; + double factor = easeFactorIdx >= 0 ? cursor.getDouble(easeFactorIdx) : 0.0; + int reps = reviewsIdx >= 0 ? cursor.getInt(reviewsIdx) : 0; + + // Store card data temporarily + Map cardData = new HashMap<>(); + cardData.put("cardId", cardId); + cardData.put("noteId", noteId); + cardData.put("due", due); + cardData.put("interval", interval); + cardData.put("factor", factor); + cardData.put("reps", reps); + + cardDataMap.put(cardId, cardData); + noteIdsSet.add(noteId); + + if (verboseLogging) { + Log.d(TAG, "Retrieved card data for card " + cardId); + } + } finally { + cursor.close(); + } + } else { + if (verboseLogging) { + Log.w(TAG, "Card cursor is null or empty for cardId: " + cardId); + } + } + } catch (Exception e) { + Log.e(TAG, "Error getting card info for cardId: " + cardId, e); + // Skip invalid card IDs - loop will continue naturally + } + } + + if (!verboseLogging && (i + batchSize < cardIds.size())) { + Log.d(TAG, "Processed " + (end) + "/" + cardIds.size() + " cards..."); + } + } + + // Step 2: Batch fetch all note info at once + if (!noteIdsSet.isEmpty()) { + ArrayList noteIdList = new ArrayList<>(noteIdsSet); + if (verboseLogging) { + Log.d(TAG, "Fetching note info for " + noteIdList.size() + " unique notes"); + } else { + Log.d(TAG, "Batch fetching note info for " + noteIdList.size() + " unique notes"); + } + + List noteInfoList = noteAPI.notesInfo(noteIdList); + Map noteInfoMap = new HashMap<>(); + for (NoteAPI.NoteInfo noteInfo : noteInfoList) { + noteInfoMap.put(noteInfo.getNoteId(), noteInfo); + } + + // Step 3: Combine card data with note info + for (Map.Entry> entry : cardDataMap.entrySet()) { + Long cardId = entry.getKey(); + Map cardData = entry.getValue(); + Long noteId = (Long) cardData.get("noteId"); + + NoteAPI.NoteInfo noteInfo = noteInfoMap.get(noteId); + if (noteInfo != null) { + String modelName = noteInfo.getModelName(); + + // Convert NoteInfo fields to Anki-Connect format: Map + Map fields = new HashMap<>(); + Map noteFields = noteInfo.getFields(); + for (Map.Entry fieldEntry : noteFields.entrySet()) { + JsonObject fieldObj = new JsonObject(); + fieldObj.addProperty("value", fieldEntry.getValue().getValue()); + fieldObj.addProperty("order", fieldEntry.getValue().getOrder()); + fields.put(fieldEntry.getKey(), fieldObj); + } + + // Create final card info map matching Anki-Connect format + Map cardInfo = new HashMap<>(); + cardInfo.put("cardId", cardId); + cardInfo.put("note", noteId); + cardInfo.put("due", cardData.get("due")); + cardInfo.put("interval", cardData.get("interval")); + cardInfo.put("factor", cardData.get("factor")); + cardInfo.put("reps", cardData.get("reps")); + cardInfo.put("modelName", modelName); + cardInfo.put("fields", fields); + + cardsInfoList.add(cardInfo); + } else { + if (verboseLogging) { + Log.w(TAG, "Could not get note info for noteId: " + noteId); + } + } + } + } + + Log.d(TAG, "Successfully processed " + cardsInfoList.size() + " cards"); + return cardsInfoList; + } +} + diff --git a/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/DeckAPI.java b/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/DeckAPI.java index a7e8fd7..328a383 100644 --- a/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/DeckAPI.java +++ b/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/DeckAPI.java @@ -1,33 +1,199 @@ package com.kamwithk.ankiconnectandroid.ankidroid_api; import android.content.Context; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.util.Log; +import com.ichi2.anki.FlashCardsContract; import com.ichi2.anki.api.AddContentApi; import java.util.HashMap; import java.util.Map; public class DeckAPI { + private static final String TAG = "DeckAPI"; private final AddContentApi api; + private final Context context; + private String cachedAuthority = null; public DeckAPI(Context context) { - api = new AddContentApi(context); + this.context = context; + this.api = new AddContentApi(context); + } + + /** + * Try to find AnkiDroid's ContentProvider by checking both release and debug authorities + * Returns the authority string (e.g., "com.ichi2.anki.debug.flashcards") + * Always manually checks both authorities and verifies accessibility to handle + * Android 11+ package visibility restrictions on Samsung and other devices. + */ + private String findAnkiDroidAuthority() { + if (cachedAuthority != null) { + return cachedAuthority; + } + + // Always manually check both release and debug authorities + // Prioritize debug build first since it has the /cards endpoint we need + // Don't rely on AddContentApi.getAnkiDroidPackageName() as it may not reflect + // which ContentProvider is actually accessible on Android 11+ devices + PackageManager pm = context.getPackageManager(); + String[] authorities = { + "com.ichi2.anki.debug.flashcards", // Debug build (prioritized - has /cards endpoint) + "com.ichi2.anki.flashcards" // Release build (fallback) + }; + + for (String authority : authorities) { + try { + android.content.pm.ProviderInfo providerInfo; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + providerInfo = pm.resolveContentProvider( + authority, + PackageManager.ComponentInfoFlags.of(0L) + ); + } else { + providerInfo = pm.resolveContentProvider(authority, 0); + } + + if (providerInfo != null) { + // Verify the ContentProvider is actually accessible by trying a test query + Uri testUri = Uri.parse("content://" + authority + "/decks"); + try { + Cursor testCursor = context.getContentResolver().query(testUri, new String[]{"_id"}, null, null, null); + if (testCursor != null) { + testCursor.close(); + Log.d(TAG, "Found accessible AnkiDroid ContentProvider with authority: " + authority + ", package: " + providerInfo.packageName); + cachedAuthority = authority; + return cachedAuthority; + } else { + Log.d(TAG, "Authority " + authority + " found but test query returned null cursor"); + } + } catch (IllegalArgumentException e) { + Log.d(TAG, "Authority " + authority + " found but URI not supported: " + e.getMessage()); + } catch (SecurityException e) { + Log.d(TAG, "Authority " + authority + " found but access denied: " + e.getMessage()); + } catch (Exception e) { + Log.d(TAG, "Authority " + authority + " found but test query failed: " + e.getMessage()); + } + } + } catch (Exception e) { + Log.d(TAG, "Error checking authority " + authority + ": " + e.getMessage()); + } + } + + Log.e(TAG, "Could not find any accessible AnkiDroid ContentProvider authority"); + return null; + } + + /** + * Manually query decks using the correct authority + */ + private Map getDeckListManual() { + String authority = findAnkiDroidAuthority(); + if (authority == null) { + return null; + } + + // Manually construct the URI with the correct authority + Uri authorityUri = Uri.parse("content://" + authority); + Uri decksUri = Uri.withAppendedPath(authorityUri, "decks"); + + Log.d(TAG, "Querying decks with URI: " + decksUri); + + Cursor cursor = context.getContentResolver().query( + decksUri, + new String[]{FlashCardsContract.Deck.DECK_ID, FlashCardsContract.Deck.DECK_NAME}, + null, + null, + null + ); + + if (cursor == null) { + Log.e(TAG, "Failed to query decks - cursor is null"); + return null; + } + + Map decks = new HashMap<>(); + try { + int deckIdIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Deck.DECK_ID); + int deckNameIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Deck.DECK_NAME); + + while (cursor.moveToNext()) { + long deckId = cursor.getLong(deckIdIdx); + String deckName = cursor.getString(deckNameIdx); + decks.put(deckId, deckName); + } + Log.d(TAG, "Successfully retrieved " + decks.size() + " decks manually"); + } catch (Exception e) { + Log.e(TAG, "Error reading deck list from cursor", e); + return null; + } finally { + cursor.close(); + } + + return decks; } public String[] deckNames() throws Exception { - Map decks = api.getDeckList(); + // Check if AnkiDroid is installed + String authority = findAnkiDroidAuthority(); + if (authority == null) { + // Try to check if it's installed as a package (even if ContentProvider isn't accessible) + try { + context.getPackageManager().getPackageInfo("com.ichi2.anki", 0); + Log.d(TAG, "AnkiDroid release package found, but ContentProvider not accessible"); + } catch (Exception e) { + Log.d(TAG, "AnkiDroid release package not found"); + } + try { + context.getPackageManager().getPackageInfo("com.ichi2.anki.debug", 0); + Log.d(TAG, "AnkiDroid debug package found, but ContentProvider not accessible"); + } catch (Exception e) { + Log.d(TAG, "AnkiDroid debug package not found"); + } + + String errorMsg = "AnkiDroid ContentProvider is not accessible. " + + "Please ensure:\n" + + "1. AnkiDroid is installed on this device/emulator\n" + + "2. AnkiconnectAndroid has been uninstalled and reinstalled after adding permissions\n" + + "3. AnkiDroid has been opened at least once to initialize its collection\n" + + "4. The READ_WRITE_DATABASE permission has been granted\n" + + "5. Try restarting both apps"; + Log.e(TAG, errorMsg); + throw new Exception(errorMsg); + } + Log.d(TAG, "Found AnkiDroid authority: " + authority); - if (decks != null) { + // Use manual query with correct authority instead of api.getDeckList() + Map decks = getDeckListManual(); + + if (decks != null && !decks.isEmpty()) { + Log.d(TAG, "Successfully retrieved " + decks.size() + " decks"); return decks.values().toArray(new String[0]); } else { - throw new Exception("Couldn't get deck names"); + String errorMsg = "Couldn't get deck names. AnkiDroid authority found: " + authority + + ". Collection may not be initialized. Please open AnkiDroid and wait for it to load."; + Log.e(TAG, errorMsg); + throw new Exception(errorMsg); } } public Map deckNamesAndIds() throws Exception { - Map temporary = api.getDeckList(); + // Check if AnkiDroid is installed + String authority = findAnkiDroidAuthority(); + if (authority == null) { + String errorMsg = "AnkiDroid is not installed or ContentProvider is not accessible. " + + "Please ensure AnkiDroid is installed and running."; + Log.e(TAG, errorMsg); + throw new Exception(errorMsg); + } + + // Use manual query with correct authority instead of api.getDeckList() + Map temporary = getDeckListManual(); Map decks = new HashMap<>(); - if (temporary != null) { + if (temporary != null && !temporary.isEmpty()) { // Reverse hashmap to get entries of (Name, ID) for (Map.Entry entry : temporary.entrySet()) { decks.put(entry.getValue(), entry.getKey()); @@ -35,7 +201,10 @@ public Map deckNamesAndIds() throws Exception { return decks; } else { - throw new Exception("Couldn't get deck names and IDs"); + String errorMsg = "Couldn't get deck names and IDs. AnkiDroid authority found: " + authority + + ". Collection may not be initialized. Please open AnkiDroid and wait for it to load."; + Log.e(TAG, errorMsg); + throw new Exception(errorMsg); } } diff --git a/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/IntegratedAPI.java b/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/IntegratedAPI.java index 093bf0e..d417d4a 100644 --- a/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/IntegratedAPI.java +++ b/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/IntegratedAPI.java @@ -28,6 +28,7 @@ public class IntegratedAPI { public final DeckAPI deckAPI; public final ModelAPI modelAPI; public final NoteAPI noteAPI; + public final CardAPI cardAPI; public final MediaAPI mediaAPI; private final AddContentApi api; // TODO: Combine all API classes??? @@ -39,6 +40,7 @@ public IntegratedAPI(Context context) { deckAPI = new DeckAPI(context); modelAPI = new ModelAPI(context); noteAPI = new NoteAPI(context); + cardAPI = new CardAPI(context, noteAPI); mediaAPI = new MediaAPI(context); api = new AddContentApi(context); diff --git a/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/NoteAPI.java b/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/NoteAPI.java index 90c7111..f0045ff 100644 --- a/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/NoteAPI.java +++ b/app/src/main/java/com/kamwithk/ankiconnectandroid/ankidroid_api/NoteAPI.java @@ -2,9 +2,12 @@ import android.content.ContentResolver; import android.content.Context; +import android.content.pm.PackageManager; import android.database.Cursor; import android.net.Uri; +import android.os.Build; import android.text.TextUtils; +import android.util.Log; import com.ichi2.anki.FlashCardsContract; import com.ichi2.anki.api.AddContentApi; @@ -12,9 +15,11 @@ import java.util.*; public class NoteAPI { + private static final String TAG = "NoteAPI"; private Context context; private final ContentResolver resolver; private final AddContentApi api; + private String cachedAuthority = null; private static final String[] MODEL_PROJECTION = {FlashCardsContract.Note.MID}; private static final String[] NOTE_ID_PROJECTION = {FlashCardsContract.Note._ID}; @@ -26,6 +31,82 @@ public NoteAPI(Context context) { api = new AddContentApi(context); } + /** + * Try to find AnkiDroid's ContentProvider by checking both release and debug authorities + * Returns the authority string (e.g., "com.ichi2.anki.debug.flashcards") + * Always manually checks both authorities and verifies accessibility to handle + * Android 11+ package visibility restrictions on Samsung and other devices. + */ + private String findAnkiDroidAuthority() { + if (cachedAuthority != null) { + return cachedAuthority; + } + + // Always manually check both release and debug authorities + // Prioritize debug build first since it has the /cards endpoint we need + // Don't rely on AddContentApi.getAnkiDroidPackageName() as it may not reflect + // which ContentProvider is actually accessible on Android 11+ devices + PackageManager pm = context.getPackageManager(); + String[] authorities = { + "com.ichi2.anki.debug.flashcards", // Debug build (prioritized - has /cards endpoint) + "com.ichi2.anki.flashcards" // Release build (fallback) + }; + + for (String authority : authorities) { + try { + android.content.pm.ProviderInfo providerInfo; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + providerInfo = pm.resolveContentProvider( + authority, + PackageManager.ComponentInfoFlags.of(0L) + ); + } else { + providerInfo = pm.resolveContentProvider(authority, 0); + } + + if (providerInfo != null) { + // Verify the ContentProvider is actually accessible by trying a test query + Uri testUri = Uri.parse("content://" + authority + "/notes"); + try { + Cursor testCursor = context.getContentResolver().query(testUri, new String[]{"_id"}, null, null, null); + if (testCursor != null) { + testCursor.close(); + Log.d(TAG, "Found accessible AnkiDroid ContentProvider with authority: " + authority + ", package: " + providerInfo.packageName); + cachedAuthority = authority; + return cachedAuthority; + } else { + Log.d(TAG, "Authority " + authority + " found but test query returned null cursor"); + } + } catch (IllegalArgumentException e) { + Log.d(TAG, "Authority " + authority + " found but URI not supported: " + e.getMessage()); + } catch (SecurityException e) { + Log.d(TAG, "Authority " + authority + " found but access denied: " + e.getMessage()); + } catch (Exception e) { + Log.d(TAG, "Authority " + authority + " found but test query failed: " + e.getMessage()); + } + } + } catch (Exception e) { + Log.d(TAG, "Error checking authority " + authority + ": " + e.getMessage()); + } + } + + Log.e(TAG, "Could not find any accessible AnkiDroid ContentProvider authority"); + return null; + } + + /** + * Build a note URI with the correct authority + */ + private Uri getNoteContentUri() { + String authority = findAnkiDroidAuthority(); + if (authority == null) { + // Fallback to standard URI if authority not found + return FlashCardsContract.Note.CONTENT_URI; + } + Uri authorityUri = Uri.parse("content://" + authority); + return Uri.withAppendedPath(authorityUri, "notes"); + } + static String escapeQueryStr(String s) { // first replace: \ -> \\ // second replace: " -> \" @@ -78,7 +159,7 @@ public Long getNoteModelId(long note_id) { // Code copied/pasted from getNote() in AddContentAPI: // https://github.com/ankidroid/Anki-Android/blob/1711e56c2b5515ab89c3424b60e60867bb65d492/api/src/main/java/com/ichi2/anki/api/AddContentApi.kt#L244 - Uri noteUri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, Long.toString(note_id)); + Uri noteUri = Uri.withAppendedPath(getNoteContentUri(), Long.toString(note_id)); Cursor cursor = this.resolver.query(noteUri, MODEL_PROJECTION, null, null, null); if (cursor == null) { @@ -99,7 +180,7 @@ public ArrayList findNotes(String query) { ArrayList noteIds = new ArrayList<>(); final Cursor cursor = this.resolver.query( - FlashCardsContract.Note.CONTENT_URI, + getNoteContentUri(), NOTE_ID_PROJECTION, query, null, @@ -195,58 +276,96 @@ public String[] getFieldNames() { public List notesInfo(ArrayList noteIds) throws Exception { List notesInfoList = new ArrayList<>(); - String nidQuery = "nid:" + TextUtils.join(",", noteIds); Map cache = new HashMap<>(); - - Cursor cursor = this.resolver.query( - FlashCardsContract.Note.CONTENT_URI, - NOTES_INFO_PROJECTION, - nidQuery, - null, - null, - null + Uri baseNoteUri = getNoteContentUri(); + + // Query each note individually using NOTES_ID endpoint for reliability + for (Long noteId : noteIds) { + try { + Uri noteUri = Uri.withAppendedPath(baseNoteUri, Long.toString(noteId)); + Log.d(TAG, "Querying note with URI: " + noteUri); + + Cursor cursor = this.resolver.query( + noteUri, + NOTES_INFO_PROJECTION, + null, + null, + null ); - if (cursor == null) { - return null; - } - - try (cursor) { - while (cursor.moveToNext()) { - - int idIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note._ID); - int midIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.MID); - int tagsIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.TAGS); - int fldsIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.FLDS); - - long id = cursor.getLong(idIdx); - long mid = cursor.getLong(midIdx); - List tags = Arrays.asList(Utility.splitTags(cursor.getString(tagsIdx))); - String[] fieldValues = Utility.splitFields(cursor.getString(fldsIdx)); - Model model = null; - - if (cache.containsKey(mid)) { - model = cache.get(mid); - } - else { - String[] fieldNames = api.getFieldList(mid); - String modelName = api.getModelName(mid); - - model = new Model(mid, modelName, fieldNames); - cache.put(mid, model); - } - - Map fields = new HashMap<>(); - String[] fieldNames = model.getFieldNames(); - - for (int i = 0; i < fieldNames.length; i++) { - String fieldName = fieldNames[i]; - String fieldValue = fieldValues[i]; - NoteInfoField noteInfoField = new NoteInfoField(fieldValue, i); - fields.put(fieldName, noteInfoField); + if (cursor != null && cursor.moveToFirst()) { + try { + int idIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note._ID); + int midIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.MID); + int tagsIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.TAGS); + int fldsIdx = cursor.getColumnIndexOrThrow(FlashCardsContract.Note.FLDS); + + long id = cursor.getLong(idIdx); + long mid = cursor.getLong(midIdx); + List tags = Arrays.asList(Utility.splitTags(cursor.getString(tagsIdx))); + String[] fieldValues = Utility.splitFields(cursor.getString(fldsIdx)); + Model model = null; + + if (cache.containsKey(mid)) { + model = cache.get(mid); + } else { + // Query model directly from ContentProvider using correct authority + String authority = findAnkiDroidAuthority(); + Uri authorityUri = Uri.parse("content://" + authority); + Uri modelsUri = Uri.withAppendedPath(authorityUri, "models"); + Uri modelUri = Uri.withAppendedPath(modelsUri, Long.toString(mid)); + + String[] modelProjection = { + FlashCardsContract.Model.NAME, + FlashCardsContract.Model.FIELD_NAMES + }; + + Cursor modelCursor = resolver.query(modelUri, modelProjection, null, null, null); + String modelName = null; + String[] fieldNames = null; + + if (modelCursor != null && modelCursor.moveToFirst()) { + try { + int nameIdx = modelCursor.getColumnIndexOrThrow(FlashCardsContract.Model.NAME); + int fieldNamesIdx = modelCursor.getColumnIndexOrThrow(FlashCardsContract.Model.FIELD_NAMES); + + modelName = modelCursor.getString(nameIdx); + String fieldNamesStr = modelCursor.getString(fieldNamesIdx); + fieldNames = Utility.splitFields(fieldNamesStr); + } finally { + modelCursor.close(); + } + } + + if (fieldNames != null && modelName != null) { + model = new Model(mid, modelName, fieldNames); + cache.put(mid, model); + } else { + Log.w(TAG, "Could not get model info for modelId: " + mid); + continue; + } + } + + Map fields = new HashMap<>(); + String[] fieldNames = model.getFieldNames(); + + for (int i = 0; i < fieldNames.length && i < fieldValues.length; i++) { + String fieldName = fieldNames[i]; + String fieldValue = fieldValues[i]; + NoteInfoField noteInfoField = new NoteInfoField(fieldValue, i); + fields.put(fieldName, noteInfoField); + } + NoteInfo noteInfo = new NoteInfo(id, model.getModelName(), tags, fields); + notesInfoList.add(noteInfo); + } finally { + cursor.close(); + } + } else { + Log.w(TAG, "Note cursor is null or empty for noteId: " + noteId); } - NoteInfo noteInfo = new NoteInfo(id, model.getModelName(), tags, fields); - notesInfoList.add(noteInfo); + } catch (Exception e) { + Log.e(TAG, "Error getting note info for noteId: " + noteId, e); + // Continue to next note } } return notesInfoList; diff --git a/app/src/main/java/com/kamwithk/ankiconnectandroid/request_parsers/Parser.java b/app/src/main/java/com/kamwithk/ankiconnectandroid/request_parsers/Parser.java index 92727dc..b586a57 100644 --- a/app/src/main/java/com/kamwithk/ankiconnectandroid/request_parsers/Parser.java +++ b/app/src/main/java/com/kamwithk/ankiconnectandroid/request_parsers/Parser.java @@ -62,6 +62,10 @@ public static String getNoteQuery(JsonObject raw_data) { return raw_data.get("params").getAsJsonObject().get("query").getAsString(); } + public static String getCardQuery(JsonObject raw_data) { + return raw_data.get("params").getAsJsonObject().get("query").getAsString(); + } + public static long getUpdateNoteFieldsId(JsonObject raw_data) { return raw_data.get("params").getAsJsonObject().get("note").getAsJsonObject().get("id").getAsLong(); } @@ -137,6 +141,15 @@ public static ArrayList getNoteIds(JsonObject raw_data) { return noteIds; } + public static ArrayList getCardIds(JsonObject raw_data) { + ArrayList cardIds = new ArrayList<>(); + JsonArray jsonCardIds = raw_data.get("params").getAsJsonObject().get("cards").getAsJsonArray(); + for(JsonElement cardId: jsonCardIds) { + cardIds.add(cardId.getAsLong()); + } + return cardIds; + } + public static String getMediaFilename(JsonObject raw_data) { return raw_data.get("params").getAsJsonObject().get("filename").getAsString(); } diff --git a/app/src/main/java/com/kamwithk/ankiconnectandroid/routing/AnkiAPIRouting.java b/app/src/main/java/com/kamwithk/ankiconnectandroid/routing/AnkiAPIRouting.java index 5b8de69..681bdec 100644 --- a/app/src/main/java/com/kamwithk/ankiconnectandroid/routing/AnkiAPIRouting.java +++ b/app/src/main/java/com/kamwithk/ankiconnectandroid/routing/AnkiAPIRouting.java @@ -70,6 +70,10 @@ private String findRoute(JsonObject raw_json) throws Exception { return storeMediaFile(raw_json); case "notesInfo": return notesInfo(raw_json); + case "findCards": + return findCards(raw_json); + case "cardsInfo": + return cardsInfo(raw_json); case "multi": JsonArray actions = Parser.getMultiActions(raw_json); JsonArray results = new JsonArray(); @@ -232,4 +236,14 @@ private String notesInfo(JsonObject raw_json) throws Exception { ArrayList noteIds = Parser.getNoteIds(raw_json); return Parser.gson.toJson(integratedAPI.noteAPI.notesInfo(noteIds)); } + + private String findCards(JsonObject raw_json) throws Exception { + String query = Parser.getCardQuery(raw_json); + return Parser.gson.toJson(integratedAPI.cardAPI.findCards(query)); + } + + private String cardsInfo(JsonObject raw_json) throws Exception { + ArrayList cardIds = Parser.getCardIds(raw_json); + return Parser.gson.toJson(integratedAPI.cardAPI.cardsInfo(cardIds)); + } } \ No newline at end of file