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:
- AddContentApi: High-level API wrapper for common operations
- 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:
- First, look up deck by exact name in current deck list
- If not found, check SharedPreferences for previously stored deck ID
- Verify the stored ID still exists in AnkiDroid
- 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:
-
Check availability:
if (!AnkiDroidHelper.isApiAvailable(context)) {
Log.e(TAG, "AnkiDroid API not available");
}
-
Verify permission:
if (helper.shouldRequestPermission()) {
helper.requestPermission(activity, REQUEST_CODE);
}
-
Test deck list:
Map<Long, String> decks = helper.getApi().getDeckList();
if (decks == null || decks.isEmpty()) {
Log.e(TAG, "No decks found");
}
-
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:
- AddContentApi: High-level operations (deck lists, deck names)
- FlashCardsContract: Direct Content Provider queries for cards and reviews
- Permission System: Runtime permission requests for database access
- Two-Step Queries: ReviewInfo for metadata, then Card for content
- 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.