Skip to main content

Overview

The card review process is the core of AnkiDroid Companion’s functionality. It involves querying AnkiDroid for scheduled cards, displaying them in notifications, and submitting review responses with ease ratings.

Querying Scheduled Cards

The queryCurrentScheduledCard() method retrieves the next due card from AnkiDroid:
@SuppressLint("Range,DirectSystemCurrentTimeMillisUsage")
public CardInfo queryCurrentScheduledCard(long deckID) {
    String[] deckArguments = new String[deckID == -1 ? 1 : 2];
    String deckSelector = "limit=?";
    deckArguments[0] = "" + 1;
    if (deckID != -1) {
        deckSelector += ",deckID=?";
        deckArguments[1] = "" + deckID;
    }
    
    // Permission check
    if (!isPermissionGranted()) {
        uiHandler.post(() -> Toast.makeText(mContext,
                R.string.permission_not_granted,
                Toast.LENGTH_SHORT).show());
    } else {
        // Query AnkiDroid's content provider
        Cursor reviewInfoCursor =
                mContext.getContentResolver().query(
                    FlashCardsContract.ReviewInfo.CONTENT_URI, 
                    null, 
                    deckSelector, 
                    deckArguments, 
                    null);
        
        if (reviewInfoCursor == null || !reviewInfoCursor.moveToFirst()) {
            if (reviewInfoCursor != null) {
                reviewInfoCursor.close();
            }
        } else {
            // Process cursor results...
        }
    }
    return null;
}
From AnkiDroidHelper.java:189-271

Query Parameters

  • limit=1: Only fetch one card at a time
  • deckID: Optional parameter to filter by specific deck (if -1, queries all decks)

Content Provider Integration

AnkiDroid exposes card data through Android’s ContentProvider API using the FlashCardsContract.ReviewInfo.CONTENT_URI.
This operation requires com.ichi2.anki.permission.READ_WRITE_DATABASE permission.

Building CardInfo Objects

When a card is found, the cursor data is mapped to a CardInfo object:
do {
    CardInfo card = new CardInfo();
    
    card.cardOrd = reviewInfoCursor.getInt(
        reviewInfoCursor.getColumnIndex(FlashCardsContract.ReviewInfo.CARD_ORD));
    card.noteID = reviewInfoCursor.getLong(
        reviewInfoCursor.getColumnIndex(FlashCardsContract.ReviewInfo.NOTE_ID));
    card.buttonCount = reviewInfoCursor.getInt(
        reviewInfoCursor.getColumnIndex(FlashCardsContract.ReviewInfo.BUTTON_COUNT));
    
    try {
        card.fileNames = new JSONArray(reviewInfoCursor.getString(
            reviewInfoCursor.getColumnIndex(FlashCardsContract.ReviewInfo.MEDIA_FILES)));
        card.nextReviewTexts = new JSONArray(reviewInfoCursor.getString(
            reviewInfoCursor.getColumnIndex(FlashCardsContract.ReviewInfo.NEXT_REVIEW_TIMES)));
    } catch (JSONException e) {
        e.printStackTrace();
    }
    
    card.cardStartTime = System.currentTimeMillis();
    cards.add(card);
} while (reviewInfoCursor.moveToNext());
From AnkiDroidHelper.java:222-238

CardInfo Fields

  • cardOrd: Card order within its note template
  • noteID: Unique identifier for the note
  • buttonCount: Number of ease buttons (typically 4)
  • fileNames: Media files attached to the card (JSON array)
  • nextReviewTexts: Scheduled review times for each ease button
  • cardStartTime: Timestamp when card was first displayed (for time tracking)

Fetching Card Content

After getting review metadata, the app fetches the actual question and answer:
for (CardInfo card : cards) {
    Uri noteUri = Uri.withAppendedPath(
        FlashCardsContract.Note.CONTENT_URI, 
        Long.toString(card.noteID));
    Uri cardsUri = Uri.withAppendedPath(noteUri, "cards");
    Uri specificCardUri = Uri.withAppendedPath(cardsUri, Integer.toString(card.cardOrd));
    
    final Cursor specificCardCursor = mContext.getContentResolver().query(
        specificCardUri,
        SIMPLE_CARD_PROJECTION,  // [ANSWER_PURE, QUESTION_SIMPLE]
        null,
        null,
        null
    );
    
    if (specificCardCursor == null || !specificCardCursor.moveToFirst()) {
        if (specificCardCursor != null) {
            specificCardCursor.close();
        }
        return null;
    } else {
        card.a = specificCardCursor.getString(
            specificCardCursor.getColumnIndex(FlashCardsContract.Card.ANSWER_PURE));
        card.q = specificCardCursor.getString(
            specificCardCursor.getColumnIndex(FlashCardsContract.Card.QUESTION_SIMPLE));
        specificCardCursor.close();
    }
}
return cards.get(0);
From AnkiDroidHelper.java:243-267

Content URI Structure

content://com.ichi2.anki.flashcards/notes/{noteID}/cards/{cardOrd}
This hierarchical URI structure maps to:
  1. Note collection
  2. Specific note by ID
  3. Cards within that note
  4. Specific card by ordinal

Submitting Reviews

When a user taps an ease button, the review is submitted with time tracking:
public void reviewCard(long noteID, int cardOrd, long cardStartTime, int ease) {
    long timeTaken = System.currentTimeMillis() - cardStartTime;
    ContentResolver cr = mContext.getContentResolver();
    Uri reviewInfoUri = FlashCardsContract.ReviewInfo.CONTENT_URI;
    
    ContentValues values = new ContentValues();
    values.put(FlashCardsContract.ReviewInfo.NOTE_ID, noteID);
    values.put(FlashCardsContract.ReviewInfo.CARD_ORD, cardOrd);
    values.put(FlashCardsContract.ReviewInfo.EASE, ease);
    values.put(FlashCardsContract.ReviewInfo.TIME_TAKEN, timeTaken);
    
    cr.update(reviewInfoUri, values, null, null);
}
From AnkiDroidHelper.java:273-284

Review Parameters

  • noteID: Identifies which note was reviewed
  • cardOrd: Identifies which card within the note
  • ease: The difficulty rating (1-4)
  • timeTaken: Milliseconds spent reviewing the card

Ease Ratings

public static final int EASE_1 = 1;  // Again
public static final int EASE_2 = 2;  // Hard
public static final int EASE_3 = 3;  // Good
public static final int EASE_4 = 4;  // Easy
From AnkiDroidHelper.java:32-35

Moving to Next Card

After submitting a review, the app automatically moves to the next card:
private fun respondCard(context: Context, ease: Int) {
    var mAnkiDroid = AnkiDroidHelper(context)
    val localState = mAnkiDroid.storedState
    
    if (localState != null) {
        // Submit the review
        mAnkiDroid.reviewCard(
            localState.noteID, 
            localState.cardOrd, 
            localState.cardStartTime, 
            ease)
    }
    
    // Move to next card
    val nextCard = mAnkiDroid.queryCurrentScheduledCard(localState.deckId)
    if (nextCard != null) {
        mAnkiDroid.storeState(localState.deckId, nextCard)
        Notifications.create().showNotification(context, nextCard, mAnkiDroid.currentDeckName, true)
    } else {
        // No more cards - show completion notification
        val emptyCard = CardInfo()
        emptyCard.cardOrd = -1
        emptyCard.noteID = -1
        mAnkiDroid.storeState(localState.deckId, emptyCard)
        Notifications.create().showNotification(context, null, "", true)
    }
}
From NotificationReceiver.kt:22-47

Review Flow

  1. Retrieve State: Get the current card info from SharedPreferences
  2. Submit Review: Send the ease rating and time to AnkiDroid
  3. Query Next Card: Fetch the next scheduled card
  4. Update State: Store the new card info
  5. Update Notification: Show the new card or completion message

Time Tracking

The app tracks how long users spend on each card:
// When card is first shown
card.cardStartTime = System.currentTimeMillis();

// When review is submitted
long timeTaken = System.currentTimeMillis() - cardStartTime;
This timing data helps AnkiDroid’s spaced repetition algorithm make better scheduling decisions.

Periodic Card Checking

The PeriodicWorker checks for new cards at regular intervals:
private fun checkNotifications() {
    var mAnkiDroid = AnkiDroidHelper(applicationContext)
    val localState = mAnkiDroid.storedState
    
    if (localState == null) {
        return
    }
    
    // Only check if current card is empty (completed deck)
    if (localState.cardOrd != -1 || localState.noteID != (-1).toLong()) {
        return
    }
    
    // Try to get next scheduled card
    val nextCard = mAnkiDroid.queryCurrentScheduledCard(localState.deckId)
    if (nextCard != null) {
        mAnkiDroid.storeState(localState.deckId, nextCard)
        Notifications.create().showNotification(
            applicationContext, 
            nextCard, 
            mAnkiDroid.currentDeckName, 
            false)
    }
}
From PeriodicWorker.kt:23-54

Empty Card Detection

An “empty card” state (cardOrd = -1, noteID = -1) indicates the deck is complete. The worker only queries for new cards when in this state.
The periodic check uses isSilent = false to notify users when new cards become available.

Permission Requirements

All card review operations require the dangerous permission:
com.ichi2.anki.permission.READ_WRITE_DATABASE
The app must request this at runtime on Android 6.0+.

Error Handling

The card query process includes several error checks:
  • Null cursor: AnkiDroid may return null if the database is unavailable
  • Empty cursor: No cards are currently scheduled
  • Permission denied: User hasn’t granted database access
  • Content not found: Specific card data may be missing
In all error cases, the method returns null, and the caller should handle this gracefully.

Notification System

Learn how cards are displayed

Deck Management

Understand deck selection

Build docs developers (and LLMs) love