Skip to main content

Overview

AnkiDroid Companion uses the AnkiDroid Content Provider API to access flashcard data without maintaining its own database. All card data, reviews, and deck information live in AnkiDroid’s database, and this app acts as a read/write client.

API Library

Dependency (build.gradle.kts:68):
implementation("com.github.ankidroid:Anki-Android:v2.17alpha8")
This provides two key classes:
  1. AddContentApi: High-level API wrapper for common operations
  2. FlashCardsContract: Content Provider contract defining URIs and column names

Permissions

Required Permission

com.ichi2.anki.permission.READ_WRITE_DATABASE
This is a dangerous permission that must be requested at runtime on Android 6.0+ (API 23+).

Permission Checking

Check if permission is needed (AnkiDroidHelper.java:73-78):
public boolean shouldRequestPermission() {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
        return false;
    }
    return ContextCompat.checkSelfPermission(mContext, READ_WRITE_PERMISSION) 
        != PackageManager.PERMISSION_GRANTED;
}

Permission Request

Request from user (AnkiDroidHelper.java:85-87):
public void requestPermission(Activity callbackActivity, int callbackCode) {
    ActivityCompat.requestPermissions(
        callbackActivity, 
        new String[]{READ_WRITE_PERMISSION}, 
        callbackCode
    );
}
Handle response (MainActivity.kt:97-115):
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    
    for ((index, _) in permissions.withIndex()) {
        val permission = permissions[index]
        val grantResult = grantResults[index]
        if (permission == AddContentApi.READ_WRITE_PERMISSION) {
            if (grantResult == PackageManager.PERMISSION_GRANTED) {
                startApp()
            } else {
                explainError("AnkiDroid Read Write permission is not granted!")
            }
        }
    }
}

API Availability

Check if AnkiDroid is installed (AnkiDroidHelper.java:66-68):
public static boolean isApiAvailable(Context context) {
    return AddContentApi.getAnkiDroidPackageName(context) != null;
}
This checks if AnkiDroid is installed AND has the API enabled.

AddContentApi

The high-level API wrapper simplifies common operations.

Initialization

Constructor (AnkiDroidHelper.java:49-55):
public AnkiDroidHelper(Context context) {
    mContext = context.getApplicationContext();
    mApi = new AddContentApi(mContext);
}

Getting Deck List

API Method: getDeckList() Returns: Map<Long, String> mapping deck IDs to deck names Usage (MainActivity.kt:121):
val deckList = mAnkiDroid.api.deckList

if (deckList != null) {
    for (item in deckList) {
        items.add(item.value)  // Deck name
        if (item.key == lastDeckId) {
            startIndex = count
        }
        count++
    }
}

Getting Deck Name from ID

API Method: getDeckName(long deckId) Usage (AnkiDroidHelper.java:120,131):
if (did != -1 && mApi.getDeckName(did) != null) {
    return did;
}

public String getCurrentDeckName() {
    StoredState state = getStoredState();
    return mApi.getDeckName(state.deckId);
}

FlashCardsContract Content Provider

For advanced operations, the app queries the Content Provider directly.

Content URIs

FlashCardsContract.ReviewInfo.CONTENT_URI    // Query/update card reviews
FlashCardsContract.Note.CONTENT_URI          // Access notes
FlashCardsContract.Card.CONTENT_URI          // Access card data

Querying Scheduled Cards

The most complex operation: retrieving the next card due for review.

Step 1: Query ReviewInfo

Code (AnkiDroidHelper.java:190-240):
@SuppressLint("Range,DirectSystemCurrentTimeMillisUsage")
public CardInfo queryCurrentScheduledCard(long deckID) {
    // Build query parameters
    String[] deckArguments = new String[deckID == -1 ? 1 : 2];
    String deckSelector = "limit=?";
    deckArguments[0] = "" + 1;  // Limit to 1 card
    
    if (deckID != -1) {
        deckSelector += ",deckID=?";
        deckArguments[1] = "" + deckID;
    }
    
    // Query for scheduled card
    Cursor reviewInfoCursor = mContext.getContentResolver().query(
        FlashCardsContract.ReviewInfo.CONTENT_URI,
        null,              // All columns
        deckSelector,      // Selection: "limit=?,deckID=?"
        deckArguments,     // Selection args: ["1", "<deckID>"]
        null               // No sort order
    );
    
    if (reviewInfoCursor == null || !reviewInfoCursor.moveToFirst()) {
        if (reviewInfoCursor != null) {
            reviewInfoCursor.close();
        }
        return null;  // No cards due
    }
    
    // Parse cursor data
    ArrayList<CardInfo> cards = new ArrayList<>();
    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());
    
    reviewInfoCursor.close();
    // Continue to Step 2...
}
ReviewInfo Columns Used:
  • CARD_ORD: Card ordinal within note (which card template)
  • NOTE_ID: Parent note ID
  • BUTTON_COUNT: Number of answer buttons (typically 4)
  • MEDIA_FILES: JSON array of associated media
  • NEXT_REVIEW_TIMES: JSON array of next review intervals

Step 2: Query Card Content

Code (AnkiDroidHelper.java:242-266):
if (cards.size() >= 1) {
    for (CardInfo card : cards) {
        // Build URI: content://com.ichi2.anki.flashcards/notes/<noteID>/cards/<cardOrd>
        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)
        );
        
        // Query for question and answer
        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);
}
Card Projection (AnkiDroidHelper.java:37-39):
public static final String[] SIMPLE_CARD_PROJECTION = {
    FlashCardsContract.Card.ANSWER_PURE,      // Answer without HTML formatting
    FlashCardsContract.Card.QUESTION_SIMPLE   // Question in simplified format
};
URI Structure:
content://com.ichi2.anki.flashcards/notes/<noteID>/cards/<cardOrd>
The app queries cards in two steps: first get review metadata (timing, buttons), then get the actual question/answer content.

Submitting Reviews

When the user answers a card, the response is submitted back to AnkiDroid.

Ease Levels

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

Review Submission

Code (AnkiDroidHelper.java:273-284):
public void reviewCard(long noteID, int cardOrd, long cardStartTime, int ease) {
    // Calculate time spent on card
    long timeTaken = System.currentTimeMillis() - cardStartTime;
    
    ContentResolver cr = mContext.getContentResolver();
    Uri reviewInfoUri = FlashCardsContract.ReviewInfo.CONTENT_URI;
    
    // Build update values
    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);
    
    // Submit review
    cr.update(reviewInfoUri, values, null, null);
}
Parameters:
  • noteID: Identifies the note
  • cardOrd: Identifies which card in the note
  • cardStartTime: When the card was first displayed (for time tracking)
  • ease: User’s response (1-4)
Effect: AnkiDroid’s scheduler processes this review and updates:
  • Card’s interval (time until next review)
  • Card’s ease factor (difficulty adjustment)
  • Review history
  • Deck statistics

Usage in Notification Response

Code (NotificationReceiver.kt:22-31):
private fun respondCard(context: Context, ease: Int) {
    var mAnkiDroid = AnkiDroidHelper(context)
    val localState = mAnkiDroid.storedState
    
    if (localState != null) {
        mAnkiDroid.reviewCard(
            localState.noteID, 
            localState.cardOrd, 
            localState.cardStartTime, 
            ease
        )
    }
    
    // Query next card...
}

Deck ID Lookup

The app implements robust deck lookup to handle deck renaming.

Finding Deck by Name

Code (AnkiDroidHelper.java:110-127):
public Long findDeckIdByName(String deckName) {
    SharedPreferences decksDb = mContext.getSharedPreferences(DECK_REF_DB, Context.MODE_PRIVATE);
    
    // Look for deckName in the deck list
    Long did = getDeckId(deckName);
    if (did != null) {
        // If the deck was found then return its id
        return did;
    } else {
        // Otherwise try to check if we have a reference to a deck that was renamed
        did = decksDb.getLong(deckName, -1);
        if (did != -1 && mApi.getDeckName(did) != null) {
            return did;  // Deck was renamed but ID still exists
        } else {
            // If the deck really doesn't exist then return null
            return null;
        }
    }
}

Get Deck ID Helper

Code (AnkiDroidHelper.java:139-149):
private Long getDeckId(String deckName) {
    Map<Long, String> deckList = mApi.getDeckList();
    if (deckList != null) {
        for (Map.Entry<Long, String> entry : deckList.entrySet()) {
            if (entry.getValue().equalsIgnoreCase(deckName)) {
                return entry.getKey();
            }
        }
    }
    return null;
}
Strategy:
  1. First, look up deck by exact name in current deck list
  2. If not found, check SharedPreferences for previously stored deck ID
  3. Verify the stored ID still exists in AnkiDroid
  4. Return null if deck truly doesn’t exist
This deck lookup strategy only works if the app hasn’t been reinstalled. After a fresh install, renamed decks won’t be found.

Complete Integration Example

Here’s a full flow from user interaction to card display:
// MainActivity.kt - User clicks refresh button
private fun onClickRefresh() {
    // 1. Get selected deck
    val decksDropdown = findViewById<Spinner>(R.id.spinner1)
    val deckName = decksDropdown.selectedItem.toString()
    val deckID = mAnkiDroid.findDeckIdByName(deckName)
    
    // 2. Store deck reference
    mAnkiDroid.storeDeckReference(deckName, deckID)
    
    // 3. Query next card
    val card = mAnkiDroid.queryCurrentScheduledCard(deckID)
    
    if (card != null) {
        // 4. Store card state
        mAnkiDroid.storeState(deckID, card)
        
        // 5. Show notification
        Notifications.create().showNotification(this, card, deckName, false)
    } else {
        // No cards available
        val emptyCard = CardInfo()
        emptyCard.cardOrd = -1
        emptyCard.noteID = -1
        mAnkiDroid.storeState(deckID, emptyCard)
        Notifications.create().showNotification(this, null, "", false)
    }
    
    // 6. Start periodic worker
    startPeriodicWorker()
}

API Limitations

Based on the codebase and README:

Current Support

  • Simple text cards with basic HTML
  • Four-button review interface (Again, Hard, Good, Easy)
  • Single deck selection
  • Read-only access to deck lists
  • Read/write access to card reviews

Known Limitations

From README.md:38-44:
Minimalistic Card Support: The app only supports simple cards with small texts that don’t include complex HTML. Cards with images, audio, or advanced formatting may not display correctly.
No Card Skipping: There’s currently no way to skip a card without answering it.

Not Implemented

  • Card creation or editing
  • Deck creation or management
  • Note editing
  • Media file display (images, audio)
  • Complex HTML rendering
  • Cloze deletion cards
  • Card suspension/burial
  • Custom study options

Error Handling

Permission Denied

if (!isPermissionGranted()) {
    uiHandler.post(() -> Toast.makeText(
        mContext,
        R.string.permission_not_granted,
        Toast.LENGTH_SHORT
    ).show());
    return null;
}

Null Checks

The app consistently checks for null returns:
val card = mAnkiDroid.queryCurrentScheduledCard(deckID)
if (card != null) {
    // Card found, show it
} else {
    // No cards, show completion notification
}

Cursor Handling

Cursor cursor = contentResolver.query(...);
if (cursor == null || !cursor.moveToFirst()) {
    if (cursor != null) {
        cursor.close();  // Always close cursor
    }
    return null;
}
// Use cursor...
cursor.close();

Testing API Integration

To test if the API is working:
  1. Check availability:
    if (!AnkiDroidHelper.isApiAvailable(context)) {
        Log.e(TAG, "AnkiDroid API not available");
    }
    
  2. Verify permission:
    if (helper.shouldRequestPermission()) {
        helper.requestPermission(activity, REQUEST_CODE);
    }
    
  3. Test deck list:
    Map<Long, String> decks = helper.getApi().getDeckList();
    if (decks == null || decks.isEmpty()) {
        Log.e(TAG, "No decks found");
    }
    
  4. Query a card:
    CardInfo card = helper.queryCurrentScheduledCard(deckId);
    if (card != null) {
        Log.d(TAG, "Question: " + card.q);
        Log.d(TAG, "Answer: " + card.a);
    }
    

Summary

AnkiDroid Companion integrates with AnkiDroid through:
  1. AddContentApi: High-level operations (deck lists, deck names)
  2. FlashCardsContract: Direct Content Provider queries for cards and reviews
  3. Permission System: Runtime permission requests for database access
  4. Two-Step Queries: ReviewInfo for metadata, then Card for content
  5. Content Values: Update pattern for submitting reviews
All card data stays in AnkiDroid’s database, making this app a thin client that provides an alternative interface for spaced repetition study.

Build docs developers (and LLMs) love