Skip to main content

Overview

AnkiDroid Companion uses Android’s notification system to display flashcards directly in your notification tray. The notification system supports both collapsed and expanded views, with interactive buttons for submitting card reviews.

Notification Architecture

The notification system is built around custom RemoteViews layouts that provide a rich UI experience directly in the notification:
  • Collapsed View: Shows the card question and deck name
  • Expanded View: Shows both question and answer with four ease rating buttons
  • Empty State: Congratulations message when deck is complete

Collapsed vs Expanded Views

The notification displays differently depending on whether it’s collapsed or expanded:

Collapsed View

When collapsed, the notification shows:
  • Card question as the header
  • “Anki • [Deck Name]” as the title
val collapsedView = RemoteViews(context.packageName, R.layout.notification_collapsed)
collapsedView.setTextViewText(R.id.textViewCollapsedHeader, questionText)
collapsedView.setTextViewText(R.id.textViewCollapsedTitle, "Anki • $deckName")

Expanded View

When expanded, the notification reveals:
  • Card question at the top
  • Card answer below
  • Four interactive buttons (Ease 1-4)
val expandedView = RemoteViews(context.packageName, R.layout.notification_expanded_full)
exandedView.setTextViewText(R.id.textViewExpandedHeader, questionText)
exandedView.setTextViewText(R.id.textViewContent, answerText)

expandedView.setOnClickPendingIntent(R.id.button1, createIntent(context,"ACTION_BUTTON_1"))
expandedView.setOnClickPendingIntent(R.id.button2, createIntent(context,"ACTION_BUTTON_2"))
expandedView.setOnClickPendingIntent(R.id.button3, createIntent(context,"ACTION_BUTTON_3"))
expandedView.setOnClickPendingIntent(R.id.button4, createIntent(context,"ACTION_BUTTON_4"))

HTML Stripping

AnkiDroid stores card content as HTML, but notifications require plain text. The app automatically strips HTML tags:
val questionText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    Html.fromHtml(card.q, Html.FROM_HTML_MODE_LEGACY).toString()
} else {
    @Suppress("DEPRECATION")
    Html.fromHtml(card.q).toString()
}

val answerText = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    Html.fromHtml(card.a, Html.FROM_HTML_MODE_LEGACY).toString()
} else {
    @Suppress("DEPRECATION")
    Html.fromHtml(card.a).toString()
}
HTML stripping uses different methods depending on Android version for compatibility.

Button Actions

Each button in the expanded notification is wired to a PendingIntent that triggers a BroadcastReceiver:
private fun createIntent(context: Context, action: String): PendingIntent {
    val intent = Intent(context, NotificationReceiver::class.java)
    intent.action = action
    return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
When a button is pressed, the NotificationReceiver handles the action:
class NotificationReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        if (context == null) return
        
        when (intent?.action) {
            "ACTION_BUTTON_1" -> respondCard(context, AnkiDroidHelper.EASE_1)
            "ACTION_BUTTON_2" -> respondCard(context, AnkiDroidHelper.EASE_2)
            "ACTION_BUTTON_3" -> respondCard(context, AnkiDroidHelper.EASE_3)
            "ACTION_BUTTON_4" -> respondCard(context, AnkiDroidHelper.EASE_4)
        }
    }
}
From NotificationReceiver.kt:8-20

Notification Behavior

The notification is configured with specific Android system behaviors:
builder = NotificationCompat.Builder(context, "channel_id")
    .setSmallIcon(R.drawable.ic_notification)
    .setStyle(NotificationCompat.DecoratedCustomViewStyle())
    .setCustomContentView(collapsedView)
    .setCustomBigContentView(expandedView)
    .setContentTitle("Anki • $deckName")
    .setPriority(NotificationCompat.PRIORITY_HIGH)
    .setSilent(isSilent)
    .setOngoing(true)  // Persistent notification

Key Properties

  • Priority: PRIORITY_HIGH ensures the notification is visible
  • Ongoing: true makes the notification persistent (cannot be swiped away while studying)
  • Silent: Controlled by the isSilent parameter to avoid notification sounds on card changes
  • DecoratedCustomViewStyle: Allows custom layouts while maintaining system UI elements

Empty State

When all cards are completed, the notification shows a congratulations message:
if (card != null) {
    // Show card notification with buttons
} else {
    collapsedView.setTextViewText(R.id.textViewCollapsedHeader, "Congrats! You've finished the deck!")
    collapsedView.setTextViewText(R.id.textViewCollapsedTitle, "Anki")
    
    val expandedView = RemoteViews(context.packageName, R.layout.notification_expanded_empty)
    expandedView.setTextViewText(R.id.textViewEmptyExpandedHeader, "Congrats! You've finished the deck!")
    expandedView.setTextViewText(R.id.textViewEmptyExpandedContent, "New notifications will arrive when it's time to study!")
    
    builder = NotificationCompat.Builder(context, "channel_id")
        // ...
        .setOngoing(false)  // Can be dismissed
}
From Notifications.kt:63-80
The empty state notification is not ongoing, allowing users to dismiss it.

Notification Management

Before showing a new notification, the app cancels the previous one to avoid duplicates:
val notification: Notification = builder.build()

val notificationManager: NotificationManager =
    context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

notificationManager.cancel(1) // Cancel the current notification
notificationManager.notify(1, notification) // Show new notification

Card Review Process

Learn how card reviews are submitted

Deck Management

Understand deck selection and storage

Build docs developers (and LLMs) love