Skip to main content

Complete Edition · 2026

Android Keyboard Design Guide

The comprehensive resource for building production-grade Android Input Method Editors (IME) from first principles to expert-level implementations. Covers API 30 through 37, Material You 3.0, Jetpack Compose, and modern UI/UX best practices.

2026 Edition API 30–37 27 Sections All Levels Material You 3.0 Made in Jurgistan
Beginner IME basics, InputMethodService lifecycle, simple keyboard UI
Intermediate Compose UI, Material You theming, gesture typing, performance optimization
Expert Custom rendering engines, accessibility architecture, security hardening, CI/CD
Section 01

Introduction to Android Keyboard Development

Beginner

Understanding the Android Input Method Editor (IME) framework, its role in the system, and what makes a production-quality keyboard application.

What is an Android Input Method Editor?

An Input Method Editor (IME) is a specialized Android application that provides text input capabilities to users across all applications on the device. Unlike standard apps, IMEs operate as system-level services with deep integration into the Android framework, mediating all text input between users and applications.

When a user taps on a text field in any application—whether composing an email, searching the web, or filling out a form—the currently active IME appears at the bottom of the screen, providing the interface through which the user generates text. This makes keyboard apps uniquely powerful: they are present in virtually every user interaction that involves text entry.

The IME Lifecycle

Android keyboards operate through a well-defined lifecycle managed by the InputMethodService base class. Understanding this lifecycle is fundamental to building reliable keyboards:

  1. Service Initialization: The IME service is created when the user selects your keyboard as their active input method in Android Settings. The system binds to your service, calling onCreate() and onInitializeInterface().
  2. Input Session Start: When a text field gains focus, the system calls onStartInput(EditorInfo, boolean), providing metadata about the input field (input type, content type, field attributes). This is where you configure your keyboard's behavior for the specific input context.
  3. View Creation: The first time input is needed, onCreateInputView() is called to generate the keyboard UI. This view is cached and reused across input sessions for performance.
  4. Input View Display: onStartInputView(EditorInfo, boolean) signals that the keyboard should become visible. This is where you apply input-specific UI changes and prepare for user interaction.
  5. Active Input: During active typing, your keyboard receives touch events, processes key presses, and communicates with the target application through an InputConnection object.
  6. Input Session End: When the user navigates away or the input field loses focus, onFinishInput() and onFinishInputView(boolean) are called to clean up state.
  7. Service Destruction: When the system needs to reclaim resources or the user switches keyboards, onDestroy() terminates the service.
Production Insight

The IME lifecycle differs from standard Activity lifecycles in critical ways. Your keyboard service may be created once and persist for hours or days, handling thousands of input sessions. Memory leaks and resource management issues that would be minor in an Activity can become catastrophic in an IME. Always clean up listeners, cancel coroutines, and release heavy resources in onFinishInputView(), not just onDestroy().

System Integration Points

Android keyboards integrate with the system through several key touchpoints:

  • InputMethodManager: The system service that manages all IMEs, handles switching between keyboards, and coordinates input sessions across applications.
  • InputConnection: The communication channel between your keyboard and the active text field. All text manipulation—insertion, deletion, selection changes—flows through this interface.
  • EditorInfo: Metadata about the current input context, including input type (text, number, email), IME options (action button type), and field attributes (multiline, autocorrect preferences).
  • Window Insets: API 30+ provides sophisticated window inset handling for edge-to-edge keyboards, allowing you to draw behind system bars while respecting safe areas.
  • VibrationEffect: System haptic feedback API for tactile key presses, integrated with user haptic preferences.
  • AudioManager: System audio for key click sounds, respecting user sound effect settings.

Why Build a Custom Keyboard?

Despite the availability of excellent system and third-party keyboards, custom keyboard development remains relevant for several compelling reasons:

  • Specialized Input Methods: Domain-specific keyboards for medical terminology, mathematical notation, programming languages, or constructed languages (like Klingon or Elvish) that standard keyboards cannot efficiently support.
  • Enhanced Privacy: On-device keyboards that never transmit typed content to external servers, critical for healthcare, legal, and government applications.
  • Accessibility Innovation: Custom keyboards optimized for specific disabilities, motor control challenges, or alternative input methods (eye-tracking, switch control, single-handed operation).
  • Brand Integration: Enterprise keyboards with organization-specific features—secure password managers, custom autocorrect dictionaries, compliance logging, or integration with corporate systems.
  • Novel Interaction Models: Experimental input paradigms like gesture-based character recognition, AI-powered contextual suggestions (including on-device language models, see Section 19), or multi-modal input combining text, voice, and drawing.

Which Android Versions This Guide Covers

This guide covers API 30 (Android 11) through API 37 (Android 17). Android 17 reached stable on June 16, 2026, so it is treated like every earlier release: its keyboard-relevant changes are listed in the API table in Section 02, and each change is explained in the chapter where you would use it. Look for the API 37 chip next to a heading.

Dated Statements

Platform rules and library versions change faster than a guide can. Statements about release status, Play Store deadlines, and library versions are dated "as of October 2026" in the text. Confirm anything that gates a release (especially Play requirements) on the linked primary documentation before you rely on it.

Prerequisites for This Guide

This guide assumes you have:

  • Kotlin proficiency: Comfort with Kotlin coroutines, sealed classes, delegated properties, and extension functions. We use Kotlin exclusively—Java examples are not provided.
  • Android fundamentals: Understanding of Services, Views, Fragments, and the Android component lifecycle. Experience building at least one production Android application.
  • Jetpack Compose familiarity: Basic knowledge of Compose UI, State, remember, and LaunchedEffect. Advanced IME implementations leverage Compose extensively.
  • UI/UX awareness: Appreciation for touch target sizes, color contrast ratios, and accessibility standards. Keyboard UX is notoriously difficult to get right.
Section 02

API Level Overview: Android 11–17 (API 30–37)

Beginner

Critical IME-related features, behavioral changes, and new capabilities introduced across Android versions from API 30 (Android 11) through API 37 (Android 17).

Why API Levels Matter for Keyboards

Unlike many Android features that can be backported through AndroidX libraries, Input Method Editor APIs are tightly coupled to the Android framework version. New IME capabilities—such as inline autofill integration, improved window insets, or dynamic color theming—are only available on specific API levels. Understanding these boundaries is essential for:

  • Setting appropriate minSdkVersion and targetSdkVersion for your keyboard app
  • Implementing feature detection and graceful degradation for older Android versions
  • Taking advantage of new APIs while maintaining backward compatibility
  • Avoiding crashes from calling unavailable methods on older devices

API 30 (Android 11) — Foundation for Modern IMEs

API 30 Released: September 2020

Key IME Features:

  • Inline Autofill Integration: IMEs can now display autofill suggestions directly within the suggestion strip, eliminating the dropdown menu. This creates a seamless UX for password managers and form fillers. Implement via InlineSuggestionsRequest and InlineSuggestion APIs.
  • Improved Window Insets: Enhanced WindowInsets.Type.ime() provides precise keyboard visibility and dimension information, critical for edge-to-edge keyboard rendering and avoiding content occlusion.
  • Package Visibility: New package visibility restrictions require IMEs to declare intent filters or use special permissions to query installed applications. This affects custom dictionary sourcing and app-specific customization.
  • Scoped Storage Enforcement: IMEs must use scoped storage for user dictionaries and theme assets. Direct filesystem access to /sdcard/ is no longer permitted.
Minimum Recommended API

API 30 represents the minimum recommended minSdkVersion for new keyboard projects in 2026. While you can technically support older versions, the inline autofill and window insets improvements are fundamental to modern keyboard UX. Supporting API 29 and below requires significant additional effort for marginal user coverage (~5% of active devices as of 2026).

API 31 (Android 12) — Material You Foundation

API 31 Released: October 2021

Key IME Features:

  • Material You Dynamic Color: Introduction of dynamic color theming based on user wallpaper. IMEs can extract color palettes via DynamicColors API and apply them to keyboard themes.
  • Splash Screen API: New splash screen requirements affect keyboard service initialization timing. IMEs should use SplashScreen.setOnExitAnimationListener() to coordinate UI visibility.
  • Expanded Haptic Feedback: VibrationEffect.Composition allows complex haptic patterns. Keyboards can create distinctive tactile feedback for different key types (letters vs. symbols vs. modifiers).
  • Blur Effects: RenderEffect.createBlurEffect() enables frosted-glass keyboard backgrounds that blur underlying content, popular in iOS-style keyboard aesthetics.
// API 31+: Dynamic color extraction for keyboard theming
val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
    if (isSystemInDarkTheme()) dynamicDarkColorScheme(context)
    else dynamicLightColorScheme(context)
} else {
    // Fallback to static color scheme for API 30
    if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
}

API 32 (Android 12L) — Large Screen Optimizations

API 32 Released: March 2022

Key IME Features:

  • Taskbar Integration: On tablets and foldables, IMEs must respect taskbar insets to avoid overlapping the persistent taskbar. Use WindowInsets.Type.systemBars() in conjunction with Type.ime().
  • Multi-Display IME Support: Improved multi-display handling for external monitors and foldable inner/outer screens. IMEs can now maintain separate state per display.
  • Split-Screen Keyboard Behavior: Enhanced split-screen APIs allow keyboards to adapt their layout when the active text field is in split-screen mode.

API 33 (Android 13) — Privacy and Predictive Back

API 33 Released: October 2022

Key IME Features:

  • Predictive Back Gesture: IMEs must implement OnBackInvokedCallback to support the new predictive back animation. When users initiate a back gesture, keyboards should animate their dismissal progressively.
  • Per-App Language Preferences: IMEs can now query and respect per-app language settings via LocaleManager.getApplicationLocales(). For example, automatically switch to Spanish layout when typing in a Spanish-configured app.
  • Clipboard Access Notifications: Reading clipboard content triggers a user-visible toast. IMEs should minimize clipboard reads and never read clipboard on keyboard launch—only when the user explicitly requests paste.
  • Themed App Icons: Keyboard launcher icons can now adapt to user theme colors via themed icon resources.
// API 33+: Predictive back implementation for IME
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun onCreateInputView(): View {
    return ComposeView(context).apply {
        setContent {
            BackHandler(enabled = true, onBack = {
                // Animate keyboard dismissal
                animateKeyboardOut()
                requestHideSelf(0)
            })
        }
    }
}

API 34 (Android 14) — Material You Refinements

API 34 Released: October 2023

Key IME Features:

  • Enhanced Dynamic Color: DynamicColors API gains fidelity improvements with expanded color roles (22 colors instead of 12). Keyboards gain access to surface-tint, outline-variant, and additional neutral tones.
  • Grammatical Inflection API: GrammaticalInflectionManager allows IMEs to adapt autocorrect and suggestions based on user's grammatical gender preferences (masculine, feminine, neuter).
  • Foreground Service Restrictions: Stricter foreground service policies affect background keyboard operations. IMEs should minimize background processing and use WorkManager for non-immediate tasks.
  • Regional Preferences: New LocalePreferences API provides temperature unit, measurement system, and calendar preferences. Useful for keyboards with calculator or unit conversion features.

API 35 (Android 15) — Edge-to-Edge Enforcement

API 35 Released: October 2024

Key IME Features:

  • Edge-to-Edge Enforcement: All apps targeting API 35 must support edge-to-edge UI. IMEs must draw behind navigation bars and handle insets correctly. No exceptions.
  • 16KB Page Size Support: Keyboards must be compatible with devices using 16KB memory pages (up from 4KB). This primarily affects native code and large bitmap loading. Google Play enforces it for updates; see Section 25 for the deadline and the checks to run.
  • Private Space: New Private Space feature isolates sensitive apps. IMEs should avoid logging keystrokes when Private Space apps are active.
  • Satellite Connectivity: While not directly IME-related, keyboards should handle offline mode gracefully on satellite-connected devices with intermittent internet.
// API 35+: Mandatory edge-to-edge insets handling
override fun onCreateInputView(): View {
    return ComposeView(context).apply {
        WindowCompat.setDecorFitsSystemWindows(window, false)
        setContent {
            KeyboardTheme {
                Box(
                    Modifier
                        .fillMaxSize()
                        .navigationBarsPadding() // Prevents keys behind nav bar
                        .imePadding() // Respects IME window insets
                ) {
                    KeyboardLayout()
                }
            }
        }
    }
}

API 36 (Android 16) — Dual-Release Model & Material 3 Expressive

API 36 Q2 2025 (major) + Q4 2025 (minor release)

Android 16 introduces a dual-release cadence: a major API release in Q2 and a minor release in Q4. This is a fundamental shift — Build.VERSION.SDK_INT alone no longer uniquely identifies feature availability. Use the new SDK_INT_FULL constant and Build.getMinorSdkVersion() for precise version checks.

// Android 16+: Precise version checking with dual-release model
import android.os.Build
import android.os.ext.SdkExtensions

// Check major SDK version (traditional)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
    // Major release features available
}

// Check full version (major + minor) for Q4 minor release features
if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1) {
    // Minor release features available (Q4 2025)
}

// Query minor version programmatically
val minorVersion: Int = Build.getMinorSdkVersion()  // 0 = major, 1+ = minor

Key IME Features:

Richer Haptic Feedback

Android 16 replaces simple predefined effects with amplitude and frequency curves that abstract away device-specific haptic hardware differences. IMEs can now create nuanced, device-adaptive tactile feedback:

  • Amplitude & Frequency Curves: Define vibration intensity and frequency over time with VibrationEffect.WaveformEnvelopeBuilder (advanced) or BasicEnvelopeBuilder (simplified). Create soft press-and-release envelopes for letter keys versus sharp impulses for error feedback.
  • Frequency Curves: Control vibration motor frequency for different tactile textures. Low frequency for "thud" feedback, high frequency for "click" feedback.
  • Device Abstraction: The system translates haptic curves to the best possible representation on each device's actuator (LRA, ERM, or piezo), ensuring consistent perceived feedback across hardware.
// Android 16+: Rich haptic curves for keyboard feedback
// BasicEnvelopeBuilder — simplified, hardware-agnostic (recommended for most IMEs)
fun createKeyPressHaptic(): VibrationEffect {
    return VibrationEffect.BasicEnvelopeBuilder()
        // intensity: 0.0–1.0 perceived strength, sharpness: 0.0–1.0 crispness
        .addControlPoint(/* intensity */ 0.8f, /* sharpness */ 0.9f, /* durationMs */ 5)
        .addControlPoint(0.5f, 0.7f, 10)   // Sustain at medium intensity
        .addControlPoint(0.0f, 0.5f, 15)   // Soft release — natural fade
        .build()
}

// WaveformEnvelopeBuilder — advanced, direct frequency control (requires haptics expertise)
fun createErrorHaptic(): VibrationEffect {
    return VibrationEffect.WaveformEnvelopeBuilder()
        // amplitude: 0.0–1.0, frequencyHz: must be within device's supported range
        .addControlPoint(1.0f, 150f, 8)    // Sharp tap
        .addControlPoint(0.0f, 150f, 40)   // Brief pause
        .addControlPoint(1.0f, 150f, 8)    // Double-tap pattern
        .addControlPoint(0.0f, 150f, 10)   // Fade out
        .build()
}

Comprehensive Accessibility Improvements

Android 16 significantly expands accessibility APIs — many directly benefit keyboard navigation and screen reader interaction:

  • Outline Text for Maximum Contrast: A new system setting renders text with outlines for users with low vision. Keyboards should test key labels with this mode enabled to ensure readability.
  • TtsSpan.TYPE_DURATION: New span type for duration values. Useful for keyboards displaying timer or countdown UI in suggestion strips.
  • Multi-Label Support: addLabeledBy() / getLabeledByList() allows associating multiple labels with a single element. Keys with primary and secondary labels (e.g., "A" with "1" superscript) can now be fully described.
  • Expandable Elements: setExpandedState() communicates expand/collapse state for popups, long-press menus, and suggestion panels to screen readers.
  • Indeterminate ProgressBars: RANGE_TYPE_INDETERMINATE for loading indicators in voice input or dictionary download states.
  • Tri-State CheckBox: Proper accessibility support for partially-checked states (useful for keyboard settings with mixed selection).
  • Supplemental Descriptions: Additional descriptive text beyond content descriptions — enables richer context for complex key arrangements.
  • Required Form Fields: setFieldRequired() marks mandatory inputs. When your keyboard serves a form, accessibility services can announce which fields are required.
  • LE Audio Hearing Aid Support: Improved audio routing for LE Audio hearing aids. Keyboards that produce audio feedback (key clicks, voice input) should respect the new audio routing APIs.
// Android 16+: Enhanced accessibility for keyboard keys
override fun onPopulateNodeForVirtualView(
    virtualViewId: Int,
    node: AccessibilityNodeInfoCompat,
) {
    val key = getKeys().getOrNull(virtualViewId) ?: return
    node.contentDescription = key.contentDescription ?: key.label.ifEmpty { "Unknown key" }
    node.className = "android.widget.Button"
    node.isClickable = true

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
        // Multi-label: primary label + superscript secondary
        key.secondaryLabel?.let { secondary ->
            node.addLabeledBy(/* secondaryLabelView */ secondaryLabelNode)
        }

        // Expandable state for long-press popup menus
        if (key.hasPopupChars) {
            node.setExpandedState(key.isPopupShowing)
        }

        // Supplemental description for complex keys
        key.hint?.let { hint ->
            node.extras.putString("androidx.view.accessibility.supplementalDescription", hint)
        }
    }
}

System-Triggered Profiling

ProfilingManager lets keyboards register for system-triggered profiles. On Android 16 the trigger that fits an IME is TRIGGER_TYPE_ANR; the cold-start, out-of-memory, and anomaly triggers arrive with Android 17 (see Section 22). Instead of manually enabling profiling during development, the system captures traces when performance issues actually occur:

// Android 16+: Register for system-triggered profiling
class KeyboardProfilingSetup(private val context: Context) {
    fun registerProfilingTriggers() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
            val pm = context.getSystemService(ProfilingManager::class.java)

            // 1. Register listener for profiling results
            pm.registerForAllProfilingResults(
                context.mainExecutor,
            ) { profilingResult ->
                if (profilingResult.errorCode == ProfilingResult.ERROR_NONE) {
                    Timber.i("[PERF] System trace captured: ${profilingResult.resultFilePath}")
                    // Upload to your analytics backend for analysis
                }
            }

            // 2. Register triggers — without this, no traces will be captured
            // (TRIGGER_TYPE_COLD_START and TRIGGER_TYPE_OOM were added in API 37)
            val triggers = listOf(
                ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_ANR)
                    .setRateLimitingPeriodHours(1)
                    .build()
            )
            pm.addProfilingTriggers(triggers)
        }
    }
}

Adaptive Refresh Rate (ARR)

Android 16 introduces Adaptive Refresh Rate (hasArrSupport()) that lets the system dynamically adjust display refresh rate per-frame. Keyboards can use getSuggestedFrameRate() to request optimal refresh rates during animations versus static display:

  • Request high refresh rate (90–120 Hz) during key press animations and gesture trails
  • Drop to low refresh rate during idle keyboard display to save battery
  • RecyclerView 1.4 includes built-in ARR support for suggestion strip scrolling

ADPF Thermal & Performance Headroom

The Android Dynamic Performance Framework now exposes CPU and GPU headroom via SystemHealthManager. Keyboards performing on-device ML (autocorrect models, voice recognition) can query available headroom before launching heavy inference:

// Android 16+: Query thermal headroom before heavy inference
// getCpuHeadroom / getGpuHeadroom return values in range [0, 100]
// where 0 = no resources available, 100 = full capacity
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
    val healthManager = context.getSystemService(SystemHealthManager::class.java)
    val cpuHeadroom = healthManager.getCpuHeadroom(null) // null = default params
    val gpuHeadroom = healthManager.getGpuHeadroom(null)

    // Defer ML inference if CPU headroom is low (< 30% available)
    if (!cpuHeadroom.isNaN() && cpuHeadroom < 30f) {
        Timber.w("[THERMAL] Low CPU headroom ($cpuHeadroom), deferring autocorrect model")
        useSimpleFallbackCorrection()
    }
}

Predictive Back Updates

  • PRIORITY_SYSTEM_NAVIGATION_OBSERVER: New priority level for keyboard back handlers. Keyboards can observe predictive back gestures without intercepting system navigation.
  • finishAndRemoveTaskCallback / moveTaskToBackCallback: New lifecycle callbacks that inform keyboards when the host activity is being dismissed via predictive back, allowing graceful keyboard hiding.

Additional API 36 Capabilities

  • Vertical Text: VERTICAL_TEXT_FLAG in EditorInfo signals that the target text field displays vertical text. CJK keyboard layouts should adapt accordingly.
  • Measurement System Customization: Users can now set preferred measurement units (metric vs. imperial) at the OS level. Keyboards providing unit conversion features should query and respect this preference.
  • Material 3 Expressive: New design language emphasizing "emotional" UI with springy animations, bolder colors, and more expressive motion. Keyboards adopting this aesthetic will stand out. Compose support is still graduating from experimental as of October 2026; see Section 10 before you depend on it.
  • Enhanced Autofill: Improvements to inline autofill UX, including passkey integration and cross-device credential sync.
  • On-Device ML Improvements: Tighter integration with on-device ML models for autocorrect, predictive text, and entity recognition. IMEs can leverage system-provided language models without shipping their own.
  • Desktop Mode Refinements: Better keyboard behavior when Android devices are connected to external displays in desktop mode.
  • Photo Picker for IME: Embedded photo picker with cloud search support — keyboards can offer image/GIF insertion with richer media browsing.
  • AGSL Graphics: RuntimeColorFilter and RuntimeXfermode for custom shader-driven key effects without OpenGL.
Targeting API 36

The major Q2 release is stable as of mid-2025. The Q4 minor release extends the API surface with additional features. As of October 2026, Google Play requires new apps and app updates to target API 36 (see Section 25), so targetSdk = 36 is the baseline for every keyboard you ship. Test your edge-to-edge, haptic, and accessibility implementations against both the major and minor release emulator images.

API 37 (Android 17) — IME Accessibility & App Memory Limits

API 37 June 2026 (major release) + Q4 2026 (minor release, expected)

Android 17 reached stable on June 16, 2026 for Pixel 6 and later, and it keeps the dual-release cadence introduced in Android 16: API 37 is the Q2 major release, and a minor release is expected in Q4 2026. Use SDK_INT_FULL for anything that ships in the minor release, exactly as described above. For keyboards, this release is less about new key-feel APIs and more about the contracts between the IME, the editor, and the system.

Key IME Features:

  • Accessibility for Composition: IMEs can tell the editor that the user is choosing between conversion candidates (TextAttribute.Builder.setTextSuggestionSelected()), and editors can pass richer text-change information to accessibility services (AccessibilityEvent.setTextChangeTypes()). A standard TextView handles this automatically for apps targeting API 37; custom editors must adopt it. See Section 09 and Section 23.
  • IME Visibility After Configuration Changes: When a configuration change such as rotation is not handled by the app, the system no longer restores the previous keyboard visibility. InputMethodManager.SHOW_IMPLICIT is deprecated in API 37. See Section 05.
  • Global Shortcuts and Physical Keyboards: Meta+Back and Meta+Shift+Back are now global navigation shortcuts that override app-specific shortcuts. For apps targeting API 37, the device also no longer shows the last-typed password character from a physical keyboard.
  • App Memory Limits: The system enforces per-process memory limits based on the device's total RAM, with tighter limits for processes that are not visible. For an IME that stays resident for days, this is the change most likely to affect you. See Section 22.
  • Mandatory Large-Screen Adaptivity: Apps targeting API 37 can no longer opt out of the system ignoring orientation, resizability, and aspect-ratio limits on screens 600 dp and wider. See Section 15.
  • Background Audio Hardening: Audio playback, audio focus, and volume APIs fail unless the app is visible or has a suitable foreground service. A new BYPASS_CONCURRENT_RECORD_AUDIO_RESTRICTION permission covers concurrent capture for sensitive audio such as calls. See Section 20.
  • New Profiling Triggers: ProfilingManager adds TRIGGER_TYPE_COLD_START, TRIGGER_TYPE_OOM, TRIGGER_TYPE_KILL_EXCESSIVE_CPU_USAGE, and TRIGGER_TYPE_ANOMALY. See Section 22.
Targeting API 37

Some of these changes apply to every app running on Android 17 (IME visibility, memory limits, the basic background-audio rules). Others apply only when you raise targetSdk to 37 (large-screen enforcement, the accessibility change, password echo). Compile against API 37 so you can call the new APIs behind SDK_INT checks, use an Android Gradle Plugin and Android Studio release that supports compileSdk = 37, and raise targetSdk only after you have run the checks in Section 24. Google Play does not require API 37 yet as of October 2026.

API Level Strategy

For new keyboard projects starting in 2026, the recommended API level configuration is:

android {
    compileSdk = 37 // Android 17 — call API 37 methods behind SDK_INT checks

    defaultConfig {
        minSdk = 30 // Android 11 for inline autofill
        targetSdk = 36 // Android 16 — required by Play Store as of Aug 31, 2026; move to 37 after testing
    }
}

This configuration provides:

  • ~85% market coverage: API 30+ encompasses the large majority of active Android devices as of 2026
  • Modern feature access: Inline autofill, dynamic colors, predictive back, edge-to-edge
  • Manageable complexity: Only 8 API levels to support (30–37), reducing testing burden and conditional code
  • Play Store compliance: Meets Google Play's targetSdk requirements
API Level Android Version Market Share (2026) Support Recommendation
30 Android 11 ~11% Minimum supported
31 Android 12 ~7% Material You foundation
32 Android 12L ~2% Tablet/foldable support
33 Android 13 ~12% Predictive back
34 Android 14 ~15% Enhanced dynamic color
35 Android 15 ~28% Edge-to-edge enforcement
36 Android 16 ~15% Play Store target, dual-release APIs, richer haptics
37 Android 17 Too new to rank (stable June 2026) Compile target, IME accessibility, memory limits, large-screen enforcement

Market share data approximated from Google Play distribution dashboard & Statcounter, March 2026

Section 03

Architecture Patterns for Production Keyboards

Intermediate

Modern architectural approaches for building maintainable, testable, and scalable keyboard applications using MVVM, clean architecture principles, and Kotlin coroutines.

Why Architecture Matters for Keyboards

Unlike traditional Android applications that users launch and close frequently, keyboard applications are long-lived services that may run continuously for days or weeks. A poorly architected keyboard accumulates memory leaks, degrades performance over time, and becomes unmaintainable as features are added. Professional keyboard architecture must prioritize:

  • Memory efficiency: The service persists across thousands of input sessions—every leaked listener or uncanceled coroutine compounds over time.
  • State management: Keyboards maintain complex state (current layout, shift state, autocorrect buffer, suggestion cache) that must remain consistent across configuration changes and input session transitions.
  • Separation of concerns: UI rendering, text processing, dictionary lookups, and machine learning inference should be cleanly separated for testability and parallel development.
  • Testability: Core business logic (text prediction, autocorrect algorithms, layout generation) must be unit-testable without requiring Android framework dependencies.

Recommended Architecture: MVVM + Repository Pattern

The Model-View-ViewModel pattern with Repository abstraction provides the best balance of testability, maintainability, and alignment with Jetpack Compose for keyboard development:

// Project structure for professional keyboard
app/
├── src/
│   ├── main/
│   │   ├── kotlin/com/example/keyboard/
│   │   │   ├── service/
│   │   │   │   └── KeyboardService.kt          // InputMethodService implementation
│   │   │   ├── ui/
│   │   │   │   ├── KeyboardUI.kt               // Compose UI root
│   │   │   │   ├── layouts/
│   │   │   │   │   ├── QwertyLayout.kt
│   │   │   │   │   ├── SymbolsLayout.kt
│   │   │   │   │   └── EmojiLayout.kt
│   │   │   │   └── theme/
│   │   │   │       ├── KeyboardTheme.kt
│   │   │   │       └── DynamicColors.kt
│   │   │   ├── viewmodel/
│   │   │   │   ├── KeyboardViewModel.kt        // State management
│   │   │   │   └── SuggestionViewModel.kt
│   │   │   ├── repository/
│   │   │   │   ├── DictionaryRepository.kt     // Dictionary data source
│   │   │   │   ├── SettingsRepository.kt       // User preferences
│   │   │   │   └── ClipboardRepository.kt
│   │   │   ├── domain/
│   │   │   │   ├── TextProcessor.kt            // Business logic
│   │   │   │   ├── AutocorrectEngine.kt
│   │   │   │   └── PredictionEngine.kt
│   │   │   └── data/
│   │   │       ├── model/
│   │   │       │   ├── KeyboardState.kt
│   │   │       │   ├── Suggestion.kt
│   │   │       │   └── KeyLayout.kt
│   │   │       └── local/
│   │   │           └── KeyboardDatabase.kt
│   │   └── res/
│   │       ├── layout/
│   │       ├── values/
│   │       └── xml/
│   │           └── method.xml                   // IME configuration
│   └── test/
│       └── kotlin/com/example/keyboard/
│           ├── domain/
│           │   ├── TextProcessorTest.kt         // Unit tests
│           │   └── AutocorrectEngineTest.kt
│           └── viewmodel/
│               └── KeyboardViewModelTest.kt
└── build.gradle.kts

KeyboardService: Minimal Service Layer

The InputMethodService subclass should be a thin coordinator that delegates to ViewModels and Repositories. It handles only lifecycle callbacks and Android framework integration:

class KeyboardService : InputMethodService() {

    // Lazy initialization to avoid premature resource allocation
    private val viewModel: KeyboardViewModel by viewModels {
        KeyboardViewModelFactory(
            dictionaryRepository = DictionaryRepository(applicationContext),
            settingsRepository = SettingsRepository(applicationContext)
        )
    }

    private val inputConnectionHandler by lazy {
        InputConnectionHandler(viewModel)
    }

    override fun onCreateInputView(): View {
        return ComposeView(context).apply {
            // Edge-to-edge window setup for API 35+
            window?.let { window ->
                WindowCompat.setDecorFitsSystemWindows(window, false)
            }

            setContent {
                KeyboardTheme {
                    KeyboardUI(
                        viewModel = viewModel,
                        onKeyPress = ::handleKeyPress,
                        onLayoutChange = viewModel::setLayout
                    )
                }
            }
        }
    }

    override fun onStartInputView(info: EditorInfo, restarting: Boolean) {
        super.onStartInputView(info, restarting)

        // Update ViewModel with input context
        viewModel.onInputContextChanged(
            inputType = info.inputType,
            imeOptions = info.imeOptions,
            packageName = info.packageName
        )

        // Initialize input connection handler
        currentInputConnection?.let { connection ->
            inputConnectionHandler.setConnection(connection)
        }
    }

    override fun onFinishInputView(finishingInput: Boolean) {
        super.onFinishInputView(finishingInput)

        // Clear input-specific state
        viewModel.onInputContextCleared()
        inputConnectionHandler.clearConnection()
    }

    private fun handleKeyPress(key: Key) {
        when (key) {
            is Key.Character -> inputConnectionHandler.commitText(key.char)
            is Key.Delete -> inputConnectionHandler.deleteBackward()
            is Key.Enter -> inputConnectionHandler.performEnter()
            is Key.Space -> inputConnectionHandler.commitText(" ")
            is Key.Shift -> viewModel.toggleShift()
            is Key.LayoutSwitch -> viewModel.setLayout(key.targetLayout)
        }
    }
}

ViewModel: State Management Hub

The ViewModel owns all keyboard state and exposes it as Compose State for reactive UI updates. This pattern eliminates callback hell and makes the keyboard behavior predictable and testable:

class KeyboardViewModel(
    private val dictionaryRepository: DictionaryRepository,
    private val settingsRepository: SettingsRepository
) : ViewModel() {

    // Keyboard state as Compose State
    private val _keyboardState = MutableStateFlow(KeyboardState())
    val keyboardState: StateFlow<KeyboardState> = _keyboardState.asStateFlow()

    // Suggestion state
    private val _suggestions = MutableStateFlow<List<Suggestion>>(emptyList())
    val suggestions: StateFlow<List<Suggestion>> = _suggestions.asStateFlow()

    // Current input buffer for autocorrect
    private var currentWord = StringBuilder()

    init {
        // Load user preferences
        viewModelScope.launch {
            settingsRepository.preferences.collect { prefs ->
                _keyboardState.update { state ->
                    state.copy(
                        enableHaptics = prefs.enableHaptics,
                        enableSounds = prefs.enableSounds,
                        theme = prefs.theme
                    )
                }
            }
        }
    }

    fun onInputContextChanged(
        inputType: Int,
        imeOptions: Int,
        packageName: String
    ) {
        val layout = determineLayout(inputType)
        val actionButton = determineActionButton(imeOptions)

        _keyboardState.update { state ->
            state.copy(
                currentLayout = layout,
                actionButton = actionButton,
                targetPackage = packageName
            )
        }

        // Clear word buffer on new input
        currentWord.clear()
        _suggestions.value = emptyList()
    }

    fun onCharacterTyped(char: Char) {
        currentWord.append(char)

        // Trigger suggestion generation
        viewModelScope.launch {
            val newSuggestions = dictionaryRepository.getSuggestions(
                prefix = currentWord.toString(),
                limit = 3
            )
            _suggestions.value = newSuggestions
        }
    }

    fun toggleShift() {
        _keyboardState.update { state ->
            state.copy(
                shiftState = when (state.shiftState) {
                    ShiftState.OFF -> ShiftState.ON
                    ShiftState.ON -> ShiftState.CAPS_LOCK
                    ShiftState.CAPS_LOCK -> ShiftState.OFF
                }
            )
        }
    }

    fun setLayout(layout: KeyboardLayout) {
        _keyboardState.update { it.copy(currentLayout = layout) }
    }

    private fun determineLayout(inputType: Int): KeyboardLayout {
        return when {
            inputType and InputType.TYPE_CLASS_NUMBER != 0 -> KeyboardLayout.NUMERIC
            inputType and InputType.TYPE_CLASS_PHONE != 0 -> KeyboardLayout.PHONE
            else -> KeyboardLayout.QWERTY
        }
    }

    private fun determineActionButton(imeOptions: Int): ActionButton {
        return when (imeOptions and EditorInfo.IME_MASK_ACTION) {
            EditorInfo.IME_ACTION_SEARCH -> ActionButton.SEARCH
            EditorInfo.IME_ACTION_SEND -> ActionButton.SEND
            EditorInfo.IME_ACTION_NEXT -> ActionButton.NEXT
            EditorInfo.IME_ACTION_GO -> ActionButton.GO
            else -> ActionButton.DONE
        }
    }
}

// State model
data class KeyboardState(
    val currentLayout: KeyboardLayout = KeyboardLayout.QWERTY,
    val shiftState: ShiftState = ShiftState.OFF,
    val actionButton: ActionButton = ActionButton.DONE,
    val enableHaptics: Boolean = true,
    val enableSounds: Boolean = true,
    val theme: KeyboardTheme = KeyboardTheme.SYSTEM,
    val targetPackage: String = ""
)

enum class ShiftState { OFF, ON, CAPS_LOCK }
enum class KeyboardLayout { QWERTY, NUMERIC, SYMBOLS, EMOJI, PHONE }
enum class ActionButton { DONE, GO, SEARCH, SEND, NEXT }
Production Pattern: Separation of Concerns

This architecture cleanly separates Android framework concerns (InputMethodService) from business logic (ViewModels, Repositories) and UI (Compose). Unit tests can verify autocorrect algorithms without instantiating InputMethodService. UI tests can verify layout rendering without touching dictionary code. This separation is critical for large keyboard projects with multiple developers.

Dependency Injection with Hilt

For complex keyboards, Hilt dependency injection simplifies dependency management and improves testability. Hilt integration for IME services requires special handling since Services aren't standard Activities:

// build.gradle.kts
plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.59.2")
    ksp("com.google.dagger:hilt-compiler:2.59.2")
}

// Application class
@HiltAndroidApp
class KeyboardApplication : Application()

// Service with Hilt
@AndroidEntryPoint
class KeyboardService : InputMethodService() {

    @Inject
    lateinit var dictionaryRepository: DictionaryRepository

    @Inject
    lateinit var settingsRepository: SettingsRepository

    // ViewModels are automatically injected via Hilt
    private val viewModel: KeyboardViewModel by viewModels()
}

// Module for dependencies
@Module
@InstallIn(SingletonComponent::class)
object KeyboardModule {

    @Provides
    @Singleton
    fun provideDictionaryRepository(
        @ApplicationContext context: Context
    ): DictionaryRepository = DictionaryRepository(context)

    @Provides
    @Singleton
    fun provideSettingsRepository(
        @ApplicationContext context: Context
    ): SettingsRepository = SettingsRepository(context)
}
Section 04

Project Setup & Build Configuration

Beginner

Set up an Android keyboard project from scratch: Gradle configuration, manifest declarations, input method XML metadata, and the minimal files needed to get a working IME on a device or emulator.

4.1 — Gradle Configuration

A keyboard app targets the same build system as any Android project, but several configuration choices directly affect IME behavior. Start with the Kotlin DSL and the latest AGP (Android Gradle Plugin) version available:

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
    id("org.jetbrains.kotlin.plugin.compose") // Kotlin 2.0+ Compose compiler
}

android {
    namespace = "com.example.keyboard"
    compileSdk = 37

    defaultConfig {
        applicationId  = "com.example.keyboard"
        minSdk         = 30    // Android 11 — inline autofill, dynamic color
        targetSdk      = 36    // Android 16 — required by Play Store as of Aug 31, 2026
        versionCode    = 1
        versionName    = "1.0.0"
    }

    buildFeatures {
        viewBinding = true          // For XML-based keyboard layouts
        compose     = true          // For Compose-based keyboards
    }

    // No composeOptions needed — Kotlin 2.0+ Compose plugin handles compiler config

    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }

    kotlinOptions {
        jvmTarget = "17"
    }
}

dependencies {
    // Core Android
    implementation("androidx.core:core-ktx:1.16.0")
    implementation("androidx.appcompat:appcompat:1.7.0")

    // Material Design 3 (dynamic color, theme tokens)
    implementation("com.google.android.material:material:1.12.0")

    // Jetpack Compose (optional — for Compose-based keyboard UI)
    implementation(platform("androidx.compose:compose-bom:2025.06.00"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui-tooling-preview")
    debugImplementation("androidx.compose.ui:ui-tooling")

    // Lifecycle
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.0")

    // Testing
    testImplementation("junit:junit:4.13.2")
    androidTestImplementation("androidx.test.ext:junit:1.2.1")
}
minSdk Considerations

API 21 is the absolute minimum for modern features. API 24 unlocks Java 8 desugaring without a library. API 28+ enables inline autofill suggestions. API 33+ enables stylus handwriting. API 35+ enables connectionless stylus handwriting. Choose based on your target audience; as of 2026, API 24 covers >99% of active devices.

4.2 — AndroidManifest.xml

The manifest declares your IME as a system-level input method service. Three elements are required: the <service> itself, the BIND_INPUT_METHOD permission, and the XML metadata:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application
        android:label="@string/app_name"
        android:icon="@mipmap/ic_launcher"
        android:supportsRtl="true"
        android:theme="@style/Theme.MyKeyboard">

        <!-- The IME Service -->
        <service
            android:name=".KeyboardService"
            android:label="@string/keyboard_name"
            android:permission="android.permission.BIND_INPUT_METHOD"
            android:exported="true">

            <intent-filter>
                <action android:name="android.view.InputMethod" />
            </intent-filter>

            <meta-data
                android:name="android.view.im"
                android:resource="@xml/method" />
        </service>

        <!-- Optional: Settings Activity -->
        <activity
            android:name=".KeyboardSettingsActivity"
            android:label="@string/settings_title"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
            </intent-filter>
        </activity>

    </application>

</manifest>

4.3 — Input Method XML Metadata

The @xml/method resource describes your keyboard's capabilities: supported language subtypes, input modes, and switching behavior:

<input-method
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:settingsActivity="com.example.keyboard.KeyboardSettingsActivity"
    android:supportsSwitchingToNextInputMethod="true">

    <!-- English QWERTY subtype -->
    <subtype
        android:label="@string/subtype_en_us"
        android:languageTag="en-US"
        android:imeSubtypeMode="keyboard"
        android:imeSubtypeExtraValue="TkeyboardLayoutSet=qwerty"
        android:isAsciiCapable="true" />

    <!-- Spanish QWERTY subtype -->
    <subtype
        android:label="@string/subtype_es"
        android:languageTag="es"
        android:imeSubtypeMode="keyboard"
        android:imeSubtypeExtraValue="TKeyboardLayoutSet=qwerty"
        android:isAsciiCapable="true" />

    <!-- Voice input subtype -->
    <subtype
        android:label="@string/subtype_voice"
        android:languageTag="en-US"
        android:imeSubtypeMode="voice"
        android:overridesImplicitlyEnabledSubtype="true" />

</input-method>
AttributePurposeExample Values
languageTagBCP 47 locale tag (replaces deprecated imeSubtypeLocale)en-US, es, fr-FR, ja
imeSubtypeModeInput modalitykeyboard, voice, handwriting
isAsciiCapableWhether the subtype can input ASCII characterstrue / false
supportsSwitchingToNextInputMethodShow globe key for IME switchingtrue (set on <input-method>)
overridesImplicitlyEnabledSubtypeOverride default subtype resolutiontrue for voice/handwriting

4.4 — Enabling the Keyboard on Device

After installing, users must manually enable the IME in Settings → System → Languages & Input → On-screen keyboard → Manage keyboards. This two-step activation (enable then select) is a deliberate security measure—Android requires explicit user consent before any third-party app can read keystrokes.

Developer Shortcut

During development, use adb to enable and select your IME without navigating system settings:

# Enable the IME
adb shell ime enable com.example.keyboard/.KeyboardService

# Set it as the current input method
adb shell ime set com.example.keyboard/.KeyboardService

# List all installed IMEs
adb shell ime list -a

4.5 — ProGuard / R8 Rules

Input method services are instantiated by the system via reflection, so you must preserve the service class and any XML-referenced classes:

# Keep the IME service class (instantiated by system)
-keep class com.example.keyboard.KeyboardService { *; }

# Keep any custom views referenced in keyboard XML layouts
-keep class * extends android.inputmethodservice.InputMethodService { *; }
-keep class * extends android.view.View {
    public <init>(android.content.Context);
    public <init>(android.content.Context, android.util.AttributeSet);
    public <init>(android.content.Context, android.util.AttributeSet, int);
}

4.6 — Project Directory Structure

A recommended directory layout for a keyboard project:

app/src/main/
├── java/com/example/keyboard/
│   ├── KeyboardService.kt          ← InputMethodService subclass
│   ├── KeyboardView.kt             ← Custom view for rendering keys
│   ├── Keyboard.kt                 ← Key layout model (rows, keys, codes)
│   ├── correction/                 ← Autocorrect, tap correction
│   ├── gesture/                    ← Swipe/gesture typing engine
│   ├── voice/                      ← Voice input integration
│   ├── theme/                      ← Theming system (colors, shapes)
│   └── settings/                   ← Preferences and data store
├── res/
│   ├── layout/
│   │   └── keyboard_view.xml       ← Main keyboard layout
│   ├── xml/
│   │   ├── method.xml              ← IME metadata
│   │   └── keyboard_qwerty.xml     ← Key definitions (optional)
│   ├── values/
│   │   ├── strings.xml
│   │   ├── colors.xml
│   │   └── themes.xml
│   └── drawable/                   ← Key backgrounds, icons
└── AndroidManifest.xml
Section 05

InputMethodService — The Core Engine

Intermediate API 3+

Deep dive into android.inputmethodservice.InputMethodService: the lifecycle, callback sequence, window management, and how every keyboard interacts with the Android text framework. Updated for API 37.

5.1 — Class Hierarchy

InputMethodService sits in a well-defined inheritance chain. Understanding each level clarifies which callbacks come from the framework versus the IME contract:

android.content.Context
  └── android.content.ContextWrapper
        └── android.app.Service
              └── android.inputmethodservice.AbstractInputMethodService
                    └── android.inputmethodservice.InputMethodService
                          └── com.example.keyboard.KeyboardService  ← Your class

AbstractInputMethodService handles the low-level IPC binder. InputMethodService provides the high-level API: view creation, input connection, insets, and fullscreen management. Your subclass overrides only the callbacks it needs.

5.2 — Lifecycle Callbacks (Complete Sequence)

The IME lifecycle is more nuanced than a typical Activity. Here is the complete callback sequence from service creation to destruction:

PhaseCallbackWhen It FiresTypical Use
CreationonCreate()Service first createdInitialize resources, theme engine, dependency injection
onInitializeInterface()Configuration changes (screen rotation, locale)Rebuild internal state, re-measure layouts
View SetuponCreateInputView()First time input view is neededInflate or compose the keyboard UI; return the root View
onCreateCandidatesView()First time candidates strip is neededInflate suggestion bar (or return null)
BindingonBindInput()Client app first connectsCache client package name, prepare per-app settings
SessiononStartInput(EditorInfo, Boolean)Editor field gains focusRead input type, action button, configure keyboard mode
onStartInputView(EditorInfo, Boolean)Keyboard becomes visibleRefresh key labels, update language, start animations
onStartCandidatesView(EditorInfo, Boolean)Candidates strip shownPre-load dictionary suggestions
TeardownonFinishInputView(Boolean)Keyboard hiddenStop animations, save draft state
onFinishInput()Editor loses focusClear composing text, release input connection
UnbindingonUnbindInput()Client app disconnectsRelease per-app resources
DestructiononDestroy()Service being killedFree native resources, close databases, cancel coroutines

5.3 — Minimal Implementation

class KeyboardService : InputMethodService() {

    private lateinit var keyboardView: KeyboardView
    private var currentEditorInfo: EditorInfo? = null

    override fun onCreateInputView(): View {
        keyboardView = layoutInflater.inflate(
            R.layout.keyboard_view, null
        ) as KeyboardView

        keyboardView.setOnKeyboardActionListener(keyListener)
        return keyboardView
    }

    override fun onStartInput(info: EditorInfo, restarting: Boolean) {
        super.onStartInput(info, restarting)
        currentEditorInfo = info
        // Adapt keyboard to the editor's input type
        configureKeyboardForInputType(info.inputType)
    }

    override fun onStartInputView(info: EditorInfo, restarting: Boolean) {
        super.onStartInputView(info, restarting)
        // Refresh visual state each time keyboard appears
        keyboardView.invalidateAllKeys()
    }

    private fun configureKeyboardForInputType(inputType: Int) {
        when (inputType and InputType.TYPE_MASK_CLASS) {
            InputType.TYPE_CLASS_NUMBER   -> keyboardView.setKeyboard(numberKeyboard)
            InputType.TYPE_CLASS_PHONE    -> keyboardView.setKeyboard(phoneKeyboard)
            InputType.TYPE_CLASS_TEXT     -> {
                when (inputType and InputType.TYPE_MASK_VARIATION) {
                    InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS -> keyboardView.showEmailLayout()
                    InputType.TYPE_TEXT_VARIATION_URI           -> keyboardView.showUrlLayout()
                    InputType.TYPE_TEXT_VARIATION_PASSWORD       -> keyboardView.disablePrediction()
                    else -> keyboardView.setKeyboard(qwertyKeyboard)
                }
            }
            else -> keyboardView.setKeyboard(qwertyKeyboard)
        }
    }

    private val keyListener = object : OnKeyboardActionListener {
        override fun onKey(primaryCode: Int, keyCodes: IntArray) {
            val ic = currentInputConnection ?: return
            when (primaryCode) {
                Keyboard.KEYCODE_DELETE -> ic.deleteSurroundingText(1, 0)
                Keyboard.KEYCODE_DONE  -> ic.performEditorAction(currentEditorInfo?.imeOptions?.and(EditorInfo.IME_MASK_ACTION) ?: EditorInfo.IME_ACTION_DONE)
                else                   -> ic.commitText(primaryCode.toChar().toString(), 1)
            }
        }
        // ... other callbacks
    }
}

5.4 — Window & Insets Management

The IME window sits in a special system layer above the app. Two callbacks control its geometry:

override fun onComputeInsets(outInsets: Insets) {
    super.onComputeInsets(outInsets)
    // The visible area of the keyboard (affects app content displacement)
    outInsets.visibleTopInsets = keyboardView.top
    outInsets.contentTopInsets = keyboardView.top
    // touchableInsets controls which region captures touch events
    outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE
}

override fun onEvaluateFullscreenMode(): Boolean {
    // Return false to prevent fullscreen extract editing in landscape
    // Most modern keyboards override this to always return false
    return false
}
Fullscreen Mode — Avoid It

In landscape orientation, the default InputMethodService enters fullscreen mode where it replaces the app UI with a text extract area. Almost all modern keyboards disable this by returning false from onEvaluateFullscreenMode(). Users expect to see the app content above the keyboard, even in landscape.

5.5 — API 33–36 Additions

Recent Android versions have expanded InputMethodService significantly:

APIMethodPurpose
33 onStartStylusHandwriting() System delegates stylus input to the IME for handwriting recognition
33 onStylusHandwritingMotionEvent() Receives each MotionEvent from the stylus during handwriting
33 getStylusHandwritingWindow() Returns the overlay window for rendering ink strokes
34 onUpdateEditorToolType(Int) Notified when user switches between finger, stylus, and mouse — adapt UI accordingly
35 onStartConnectionlessStylusHandwriting() Stylus handwriting without an active InputConnection (e.g., lock screen)
35 finishConnectionlessStylusHandwriting() Complete connectionless handwriting and return recognized text
36 onShouldVerifyKeyEvent(KeyEvent) Verify that a KeyEvent originated from the system (HMAC-based hardware key verification)
// API 34: Adapt keyboard UI based on input tool
override fun onUpdateEditorToolType(toolType: Int) {
    super.onUpdateEditorToolType(toolType)
    when (toolType) {
        MotionEvent.TOOL_TYPE_STYLUS -> showHandwritingMode()
        MotionEvent.TOOL_TYPE_FINGER -> showStandardKeyboard()
        MotionEvent.TOOL_TYPE_MOUSE  -> showDesktopLayout()
    }
}

// API 33: Stylus handwriting support
override fun onStartStylusHandwriting(): Boolean {
    // Return true if your IME supports handwriting recognition
    inkCanvas.clear()
    inkCanvas.visibility = View.VISIBLE
    return true
}

override fun onStylusHandwritingMotionEvent(event: MotionEvent): Boolean {
    // Render ink strokes and accumulate points for recognition
    inkCanvas.onTouchEvent(event)
    if (event.action == MotionEvent.ACTION_UP) {
        recognizeHandwriting(inkCanvas.getStrokePoints())
    }
    return true
}

5.6 — IME Switching

The globe key behavior is controlled by your method.xml attribute supportsSwitchingToNextInputMethod="true" and the runtime API:

// Show globe key only if other IMEs are available
fun shouldShowGlobeKey(): Boolean {
    return shouldOfferSwitchingToNextInputMethod()
}

// Switch to next IME when globe key is tapped
fun onGlobeKeyTapped() {
    switchToNextInputMethod(false /* onlyCurrentIme */)
}

// Switch to a specific IME by token
fun switchToSpecificIme(imeId: String) {
    switchInputMethod(imeId)
}

5.7 — API 37 (Android 17) Changes API 37

Android 17 changes how the system restores keyboard visibility and which hardware shortcuts it reserves. None of it adds a callback to InputMethodService, but each item affects how you write your keyboard's own screens and how you test.

IME Visibility After Configuration Changes

Beginning with Android 17, when the device configuration changes (rotation, for example) and the foreground app does not handle the change itself, the system does not restore the previous IME visibility. The keyboard that was open before the rotation stays closed until something asks for it. You cannot fix this from inside your IME, because the decision belongs to the host app. You can fix it in your own activities, such as a "try your keyboard" screen in onboarding or a settings screen with text fields:

// API 37: the previous IME visibility is no longer restored after an unhandled
// configuration change, so ask for the keyboard again when your screen needs it
class TryKeyboardActivity : AppCompatActivity() {
    private lateinit var tryField: EditText

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_try_keyboard)
        tryField = findViewById(R.id.try_field)
        showKeyboard()
    }

    private fun showKeyboard() {
        tryField.requestFocus()
        val imm = getSystemService(InputMethodManager::class.java)
        // SHOW_IMPLICIT is deprecated in API 37 — pass 0 instead of the flag
        tryField.post { imm.showSoftInput(tryField, 0) }
    }
}

The platform documentation lists two mitigations: set android:windowSoftInputMode="stateAlwaysVisible" on the activity, or request the keyboard programmatically in onCreate() (or in onConfigurationChanged() if you handle the change yourself).

Support Tip

If users report that your keyboard disappears after rotating the phone in a particular app, check whether that app handles configuration changes. On Android 17 this is the expected platform behavior, not a crash in your IME. Add it to your Section 24 test plan so you recognize it quickly.

SHOW_IMPLICIT Is Deprecated

InputMethodManager.SHOW_IMPLICIT is deprecated in API 37, and the API reference notes that the flag no longer has any effect. Call showSoftInput(View, int) without it, as in the snippet above. Search your settings and onboarding code for the constant and remove it.

Reserved Global Shortcuts

Meta+Back and Meta+Shift+Back are now global keyboard navigation shortcuts that override app-specific shortcuts. If your keyboard maps hardware key combinations (a desktop-style layout, a shortcut row, or onKeyDown() handling for external keyboards), treat those two combinations as reserved and do not bind your own actions to them.

Section 06

Layout System — Rows, Keys, and XML

Intermediate

Define keyboard layouts using XML or programmatic approaches: row definitions, key sizing in proportional units, gap management, special keys, and multi-layout switching between QWERTY, symbols, and number pads.

6.1 — XML-Based Key Definitions

The traditional approach uses XML resources parsed at runtime. Each <Row> contains <Key> elements with codes, labels, widths, and visual attributes:

<Keyboard xmlns:android="http://schemas.android.com/apk/res/android"
    android:keyWidth="10%p"
    android:keyHeight="56dp"
    android:horizontalGap="0.5%p"
    android:verticalGap="6dp">

    <!-- Row 1: Q W E R T Y U I O P -->
    <Row>
        <Key android:codes="113" android:keyLabel="q" android:keyEdgeFlags="left" />
        <Key android:codes="119" android:keyLabel="w" />
        <Key android:codes="101" android:keyLabel="e" />
        <Key android:codes="114" android:keyLabel="r" />
        <Key android:codes="116" android:keyLabel="t" />
        <Key android:codes="121" android:keyLabel="y" />
        <Key android:codes="117" android:keyLabel="u" />
        <Key android:codes="105" android:keyLabel="i" />
        <Key android:codes="111" android:keyLabel="o" />
        <Key android:codes="112" android:keyLabel="p" android:keyEdgeFlags="right" />
    </Row>

    <!-- Row 2: A S D F G H J K L (with half-key indent) -->
    <Row>
        <Key android:codes="97"  android:keyLabel="a"
             android:horizontalGap="5%p" android:keyEdgeFlags="left" />
        <Key android:codes="115" android:keyLabel="s" />
        <Key android:codes="100" android:keyLabel="d" />
        <Key android:codes="102" android:keyLabel="f" />
        <Key android:codes="103" android:keyLabel="g" />
        <Key android:codes="104" android:keyLabel="h" />
        <Key android:codes="106" android:keyLabel="j" />
        <Key android:codes="107" android:keyLabel="k" />
        <Key android:codes="108" android:keyLabel="l" android:keyEdgeFlags="right" />
    </Row>

    <!-- Row 3: Shift + Z X C V B N M + Delete -->
    <Row>
        <Key android:codes="-1" android:keyLabel="⇧"
             android:keyWidth="15%p" android:isModifier="true"
             android:isSticky="true" android:keyEdgeFlags="left" />
        <Key android:codes="122" android:keyLabel="z" />
        <Key android:codes="120" android:keyLabel="x" />
        <Key android:codes="99"  android:keyLabel="c" />
        <Key android:codes="118" android:keyLabel="v" />
        <Key android:codes="98"  android:keyLabel="b" />
        <Key android:codes="110" android:keyLabel="n" />
        <Key android:codes="109" android:keyLabel="m" />
        <Key android:codes="-5" android:keyLabel="⌫"
             android:keyWidth="15%p" android:isRepeatable="true"
             android:keyEdgeFlags="right" />
    </Row>

    <!-- Row 4: Symbols, Comma, Space, Period, Enter -->
    <Row android:rowEdgeFlags="bottom">
        <Key android:codes="-2" android:keyLabel="?123" android:keyWidth="15%p"
             android:keyEdgeFlags="left" />
        <Key android:codes="44"  android:keyLabel="," android:keyWidth="10%p" />
        <Key android:codes="32"  android:keyLabel=" " android:keyWidth="40%p"
             android:isRepeatable="true" />
        <Key android:codes="46"  android:keyLabel="." android:keyWidth="10%p" />
        <Key android:codes="-3" android:keyLabel="↵" android:keyWidth="15%p"
             android:keyEdgeFlags="right" />
    </Row>

</Keyboard>

6.2 — Key Size System (%p Units)

Android keyboard XML uses %p (percent of parent) for widths. This simplifies responsive design across screen densities:

Key RoleTypical Width%p ValueTouch Target
Letter key1/10 of row10%p~40dp on a 400dp-wide screen
Shift / Delete1.5× letter15%p~60dp
Space bar4× letter40%p~160dp
Symbols toggle1.5× letter15%p~60dp
Key height (rows)Absolute dp52–58dpMaterial recommends ≥48dp touch targets
Minimum Touch Targets

Material Design accessibility guidelines require a minimum 48×48dp touch target. For keyboard keys, the visual size can be smaller as long as the touchable area (including gap padding) meets this threshold. Use horizontalGap and verticalGap to add dead zones between keys that still count toward the touch area.

6.3 — Programmatic Layouts

XML layouts are simple but inflexible. For dynamic keyboards (runtime language switching, adaptive sizing, custom shapes), build layouts programmatically:

data class KeyDefinition(
    val code: Int,
    val label: String,
    val widthWeight: Float = 1.0f,
    val isModifier: Boolean = false,
    val isRepeatable: Boolean = false,
)

data class RowDefinition(
    val keys: List<KeyDefinition>,
    val indentWeight: Float = 0f,    // Left indent in key-width units
)

object QwertyLayout {
    fun build(): List<RowDefinition> = listOf(
        RowDefinition(
            keys = "qwertyuiop".map { KeyDefinition(it.code, it.toString()) }
        ),
        RowDefinition(
            keys = "asdfghjkl".map { KeyDefinition(it.code, it.toString()) },
            indentWeight = 0.5f
        ),
        RowDefinition(
            keys = listOf(
                KeyDefinition(-1, "⇧", widthWeight = 1.5f, isModifier = true),
                *"zxcvbnm".map { KeyDefinition(it.code, it.toString()) }.toTypedArray(),
                KeyDefinition(-5, "⌫", widthWeight = 1.5f, isRepeatable = true),
            )
        ),
        RowDefinition(
            keys = listOf(
                KeyDefinition(-2, "?123", widthWeight = 1.5f),
                KeyDefinition(44, ","),
                KeyDefinition(32, " ", widthWeight = 4.0f),
                KeyDefinition(46, "."),
                KeyDefinition(-3, "↵", widthWeight = 1.5f),
            )
        ),
    )
}

6.4 — Multi-Layout Switching

Most keyboards need at least three layouts: letters (QWERTY), symbols page 1, and symbols page 2. Manage switching with a simple state machine:

enum class KeyboardMode {
    LETTERS,        // QWERTY / QWERTZ / AZERTY
    SYMBOLS_1,      // !@#$%^&*()-+=
    SYMBOLS_2,      // ~`{}[]|\/<>
    NUMBER,         // 0-9 numeric pad
    PHONE,          // Telephone dialer layout
}

class LayoutManager(private val keyboardView: KeyboardView) {
    private var currentMode = KeyboardMode.LETTERS

    fun switchTo(mode: KeyboardMode) {
        currentMode = mode
        val layout = when (mode) {
            KeyboardMode.LETTERS   -> QwertyLayout.build()
            KeyboardMode.SYMBOLS_1 -> SymbolsLayout.page1()
            KeyboardMode.SYMBOLS_2 -> SymbolsLayout.page2()
            KeyboardMode.NUMBER    -> NumberLayout.build()
            KeyboardMode.PHONE     -> PhoneLayout.build()
        }
        keyboardView.setLayout(layout)
    }

    fun handleModeKey(code: Int) {
        when (code) {
            -2 -> switchTo(
                if (currentMode == KeyboardMode.LETTERS) KeyboardMode.SYMBOLS_1
                else KeyboardMode.LETTERS
            )
            -6 -> switchTo(KeyboardMode.SYMBOLS_2)    // ALT key
        }
    }
}

6.5 — Internationalization Considerations

Different languages require different row structures. Russian Cyrillic has 33 characters (vs. 26 Latin), requiring an extra column or row. Japanese needs a mode switch between Romaji, Hiragana, and Katakana. Plan your layout system to be flexible from the start.

Section 07

Compose UI for Keyboards

Intermediate API 21+

Build keyboard UIs with Jetpack Compose: integrating ComposeView inside InputMethodService, managing recomposition performance, state hoisting for key states, and bridging Compose with the IME window system.

7.1 — Why Compose for Keyboards?

Compose offers real advantages over XML layouts for keyboards: declarative UI reduces state bugs, animations are first-class, and theming (especially Material You dynamic color) works out of the box. The trade-off is recomposition performance—a keyboard must render at 60fps with zero frame drops during fast typing.

ApproachStrengthsWeaknesses
Custom View + CanvasMaximum control, zero allocation during draw, lowest latencyComplex state management, manual invalidation
XML LayoutFamiliar, stable, works everywhereVerbose, hard to animate, poor dynamic theming
Jetpack ComposeDeclarative, excellent theming, built-in animationsRecomposition overhead, ComposeView in Service requires care

7.2 — Embedding ComposeView in InputMethodService

InputMethodService.onCreateInputView() must return a View. Wrap your Compose keyboard in a ComposeView:

class KeyboardService : InputMethodService() {

    override fun onCreateInputView(): View {
        return ComposeView(this).apply {
            setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
            )
            setContent {
                KeyboardTheme {
                    KeyboardScreen(
                        onKeyPress = ::handleKeyPress,
                        onBackspace = ::handleBackspace,
                        onEnter = ::handleEnter,
                    )
                }
            }
        }
    }

    private fun handleKeyPress(char: Char) {
        currentInputConnection?.commitText(char.toString(), 1)
    }

    private fun handleBackspace() {
        currentInputConnection?.deleteSurroundingText(1, 0)
    }

    private fun handleEnter() {
        currentInputConnection?.performEditorAction(EditorInfo.IME_ACTION_DONE)
    }
}

7.3 — Compose Keyboard Layout

@Composable
fun KeyboardScreen(
    onKeyPress: (Char) -> Unit,
    onBackspace: () -> Unit,
    onEnter: () -> Unit,
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(MaterialTheme.colorScheme.surfaceContainerLow)
            .padding(horizontal = 4.dp, vertical = 6.dp),
    ) {
        val rows = remember { QwertyLayout.build() }
        rows.forEach { row ->
            KeyboardRow(
                row = row,
                onKeyPress = onKeyPress,
                onBackspace = onBackspace,
                onEnter = onEnter,
            )
            Spacer(Modifier.height(6.dp))
        }
    }
}

@Composable
fun KeyboardRow(
    row: RowDefinition,
    onKeyPress: (Char) -> Unit,
    onBackspace: () -> Unit,
    onEnter: () -> Unit,
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        if (row.indentWeight > 0f) {
            Spacer(Modifier.weight(row.indentWeight))
        }
        row.keys.forEach { key ->
            KeyButton(
                definition = key,
                modifier = Modifier.weight(key.widthWeight),
                onPress = {
                    when (key.code) {
                        -5   -> onBackspace()
                        -3   -> onEnter()
                        else -> onKeyPress(key.code.toChar())
                    }
                },
            )
        }
        if (row.indentWeight > 0f) {
            Spacer(Modifier.weight(row.indentWeight))
        }
    }
}

@Composable
fun KeyButton(
    definition: KeyDefinition,
    modifier: Modifier = Modifier,
    onPress: () -> Unit,
) {
    var isPressed by remember { mutableStateOf(false) }
    val elevation by animateDpAsState(
        targetValue = if (isPressed) 1.dp else 4.dp,
        label = "keyElevation",
    )

    Surface(
        modifier = modifier
            .height(52.dp)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        isPressed = true
                        tryAwaitRelease()
                        isPressed = false
                        onPress()
                    }
                )
            },
        shape = RoundedCornerShape(8.dp),
        color = MaterialTheme.colorScheme.surfaceContainerHigh,
        shadowElevation = elevation,
    ) {
        Box(contentAlignment = Alignment.Center) {
            Text(
                text = definition.label,
                style = MaterialTheme.typography.bodyLarge,
                fontWeight = FontWeight.Medium,
            )
        }
    }
}

7.4 — Recomposition Performance

Keyboard performance is non-negotiable. A recomposition that drops to 30fps during rapid typing is a deal-breaker. Follow these Compose optimizations:

TechniqueImpactImplementation
Use key() for stable compositionPrevents unnecessary recomposition of unchanged keyskey(key.code) { KeyButton(...) }
Hoist pressed state to parentOnly recomposes the pressed key, not the entire rowvar pressedKey by remember { mutableStateOf(-1) }
Use derivedStateOfDebounces recomposition for computed valuesval isShifted by remember { derivedStateOf { shift > 0 } }
Avoid allocations in drawReduces GC pauses during fast typingPre-allocate Paint, Path, RectF objects
Use Modifier.graphicsLayerHardware-accelerated transforms without recompositionScale and alpha changes via lambda, not state
Measure Before Optimizing

Use the Compose Compiler Metrics (-PcomposeCompilerReports=true) to identify unstable classes causing recomposition. The Layout Inspector's recomposition counter shows which composables recompose on every frame. Target: zero recompositions per frame during typing except the active key.

Section 08

Keyboard Views — Custom Rendering

Intermediate

Build a high-performance custom View for keyboard rendering: Canvas drawing, touch event handling, double-buffering, key press animations, and achieving consistent 60fps rendering under fast typing loads.

8.1 — Why Custom Views?

The framework's android.inputmethodservice.KeyboardView was deprecated in API 29. It was never suitable for production keyboards: no hardware acceleration, rigid styling, and poor multi-touch support. Every shipping keyboard uses a custom View or SurfaceView subclass.

8.2 — View Architecture

abstract class AbstractKeyboardView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {

    // Model
    var keyboard: Keyboard? = null
        set(value) {
            field = value
            requestLayout()
            invalidate()
        }

    // Double-buffer for off-screen rendering
    private var buffer: Bitmap? = null
    private var bufferCanvas: Canvas? = null
    private var bufferDirty = true

    // Pre-allocated drawing objects (avoid GC during onDraw)
    private val keyPaint    = Paint(Paint.ANTI_ALIAS_FLAG)
    private val textPaint   = Paint(Paint.ANTI_ALIAS_FLAG)
    private val keyRect     = RectF()
    private val cornerRadius = 8f * resources.displayMetrics.density

    // Touch state
    private var currentKeyIndex = -1
    private var touchDownTime   = 0L
    private val longPressTimeout = ViewConfiguration.getLongPressTimeout().toLong()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val kb = keyboard
        if (kb == null) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
            return
        }
        val width = MeasureSpec.getSize(widthMeasureSpec)
        val height = kb.height + paddingTop + paddingBottom
        setMeasuredDimension(width, height)
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        buffer?.recycle()
        buffer = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
        bufferCanvas = Canvas(buffer!!)
        bufferDirty = true
    }

    override fun onDraw(canvas: Canvas) {
        val buf = buffer ?: return
        if (bufferDirty) {
            drawKeyboard(bufferCanvas!!)
            bufferDirty = false
        }
        canvas.drawBitmap(buf, 0f, 0f, null)
        // Draw press highlight on top of buffer (avoids full redraw)
        drawPressHighlight(canvas)
    }

    private fun drawKeyboard(canvas: Canvas) {
        canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)
        val kb = keyboard ?: return
        for (key in kb.keys) {
            drawKey(canvas, key)
        }
    }

    protected open fun drawKey(canvas: Canvas, key: Keyboard.Key) {
        keyRect.set(
            key.x.toFloat(),
            key.y.toFloat(),
            (key.x + key.width).toFloat(),
            (key.y + key.height).toFloat(),
        )
        // Key background
        keyPaint.color = if (key.pressed) pressedColor else keyColor
        canvas.drawRoundRect(keyRect, cornerRadius, cornerRadius, keyPaint)
        // Key label
        textPaint.textSize = key.label?.let { 20f * resources.displayMetrics.density } ?: 0f
        textPaint.color = textColor
        textPaint.textAlign = Paint.Align.CENTER
        key.label?.let { label ->
            val textX = keyRect.centerX()
            val textY = keyRect.centerY() - (textPaint.descent() + textPaint.ascent()) / 2
            canvas.drawText(label.toString(), textX, textY, textPaint)
        }
    }

    fun invalidateKey(keyIndex: Int) {
        // Partial invalidation: only redraw the affected key region
        val kb = keyboard ?: return
        val key = kb.keys.getOrNull(keyIndex) ?: return
        invalidate(key.x, key.y, key.x + key.width, key.y + key.height)
        bufferDirty = true
    }

    fun invalidateAllKeys() {
        bufferDirty = true
        invalidate()
    }
}

8.3 — Touch Event Handling

override fun onTouchEvent(event: MotionEvent): Boolean {
    val kb = keyboard ?: return false
    val x = event.x.toInt()
    val y = event.y.toInt()

    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            touchDownTime = SystemClock.uptimeMillis()
            val keyIndex = getKeyAtPoint(x, y)
            if (keyIndex >= 0) {
                currentKeyIndex = keyIndex
                kb.keys[keyIndex].pressed = true
                invalidateKey(keyIndex)
                actionListener?.onPress(kb.keys[keyIndex].codes[0])
                // Schedule long-press detection
                handler.postDelayed(longPressRunnable, longPressTimeout)
            }
        }

        MotionEvent.ACTION_MOVE -> {
            val newIndex = getKeyAtPoint(x, y)
            if (newIndex != currentKeyIndex && newIndex >= 0) {
                // Finger slid to a different key
                if (currentKeyIndex >= 0) {
                    kb.keys[currentKeyIndex].pressed = false
                    invalidateKey(currentKeyIndex)
                }
                currentKeyIndex = newIndex
                kb.keys[newIndex].pressed = true
                invalidateKey(newIndex)
                handler.removeCallbacks(longPressRunnable)
            }
        }

        MotionEvent.ACTION_UP -> {
            handler.removeCallbacks(longPressRunnable)
            if (currentKeyIndex >= 0) {
                val key = kb.keys[currentKeyIndex]
                key.pressed = false
                invalidateKey(currentKeyIndex)
                actionListener?.onKey(key.codes[0], key.codes)
                actionListener?.onRelease(key.codes[0])
            }
            currentKeyIndex = -1
        }

        MotionEvent.ACTION_CANCEL -> {
            handler.removeCallbacks(longPressRunnable)
            if (currentKeyIndex >= 0) {
                kb.keys[currentKeyIndex].pressed = false
                invalidateKey(currentKeyIndex)
            }
            currentKeyIndex = -1
        }
    }
    return true
}

private fun getKeyAtPoint(x: Int, y: Int): Int {
    val kb = keyboard ?: return -1
    return kb.keys.indexOfFirst { key ->
        x >= key.x && x < key.x + key.width &&
        y >= key.y && y < key.y + key.height
    }
}

8.4 — Performance Checklist

RuleWhyViolation Cost
Zero allocations in onDraw()GC pauses cause frame drops~2–6ms per minor GC on mid-range devices
Pre-allocate Paint, RectF, PathReuse objects across framesEach new Paint() allocates ~400 bytes
Use invalidateKey() not invalidate()Partial invalidation redraws only the changed regionFull redraw costs 2–5× more than single key
Double-buffer keyboard stateAvoid redrawing all 30+ keys each frameWithout buffering: ~3ms per frame on Pixel 8
Hardware-accelerated canvasGPU-backed drawing for shadows and anti-aliasingSoftware rendering: 4–8× slower shadows
Section 09

InputConnection — Talking to the Text Field

Intermediate

Master the InputConnection API: committing text, composing regions, cursor movement, text selection, editor actions, and handling the quirks of different text fields across apps.

9.1 — Core Concepts

InputConnection is the channel between your keyboard and the app's text field. Every keystroke, backspace, autocorrect replacement, and cursor movement flows through this interface. The connection is obtained via currentInputConnection in your InputMethodService.

Two critical patterns exist: direct commit (simple characters) and composing text (autocorrect preview).

9.2 — Direct Text Commitment

fun commitCharacter(char: Char) {
    val ic = currentInputConnection ?: return
    ic.commitText(char.toString(), 1)    // 1 = move cursor after the inserted text
}

fun commitWord(word: String) {
    val ic = currentInputConnection ?: return
    ic.commitText(word, 1)
}

fun deleteBackward(count: Int = 1) {
    val ic = currentInputConnection ?: return
    // Delete 'count' characters before cursor, 0 characters after
    ic.deleteSurroundingText(count, 0)
}

fun deleteForward(count: Int = 1) {
    val ic = currentInputConnection ?: return
    ic.deleteSurroundingText(0, count)
}

fun performAction(editorInfo: EditorInfo) {
    val ic = currentInputConnection ?: return
    val action = editorInfo.imeOptions and EditorInfo.IME_MASK_ACTION
    ic.performEditorAction(action)
    // Common actions: IME_ACTION_DONE, IME_ACTION_SEARCH,
    //                 IME_ACTION_NEXT, IME_ACTION_GO, IME_ACTION_SEND
}

9.3 — Composing Text (Autocorrect Preview)

Composing text shows an underlined preview in the text field while the user types. When they confirm (space, punctuation), you commit the final text and clear the composing region:

class ComposingManager(private val service: InputMethodService) {
    private val composingBuffer = StringBuilder()

    fun onKeyPress(char: Char) {
        composingBuffer.append(char)
        val ic = service.currentInputConnection ?: return

        // Show composing text with underline in the text field
        ic.setComposingText(composingBuffer, 1)

        // Get autocorrect candidates for the composing buffer
        val candidates = dictionary.getSuggestions(composingBuffer.toString())
        updateCandidateBar(candidates)
    }

    fun commitTopCandidate() {
        val ic = service.currentInputConnection ?: return
        val bestWord = dictionary.getTopSuggestion(composingBuffer.toString())
            ?: composingBuffer.toString()

        // finishComposingText() removes the underline and commits as-is
        // commitText() replaces the composing region with the corrected word
        ic.commitText("$bestWord ", 1)    // Add trailing space
        composingBuffer.clear()
    }

    fun commitAsTyped() {
        val ic = service.currentInputConnection ?: return
        ic.finishComposingText()
        composingBuffer.clear()
    }
}
Composing vs. Committing

setComposingText() creates a temporary region (shown underlined) that can be freely replaced until the user confirms. commitText() permanently inserts text and clears any composing region. finishComposingText() locks the composing text as-is without replacement. Always call one of the commit methods before switching text fields — leftover composing state causes visual artifacts.

9.4 — Reading Context

Tap correction and autocomplete need to read the text around the cursor:

fun getContext(): Pair<String, String> {
    val ic = currentInputConnection ?: return "" to ""
    val before = ic.getTextBeforeCursor(50, 0)?.toString().orEmpty()
    val after  = ic.getTextAfterCursor(50, 0)?.toString().orEmpty()
    return before to after
}

fun getWordBeforeCursor(): String {
    val (before, _) = getContext()
    return before.trimEnd().split(' ').lastOrNull().orEmpty()
}

fun getSelectedText(): String? {
    val ic = currentInputConnection ?: return null
    return ic.getSelectedText(0)?.toString()
}

9.5 — Cursor & Selection Control

fun moveCursorLeft() {
    val ic = currentInputConnection ?: return
    // Extract existing selection
    val extracted = ic.getExtractedText(ExtractedTextRequest(), 0)
    val pos = extracted?.selectionStart ?: return
    if (pos > 0) {
        ic.setSelection(pos - 1, pos - 1)
    }
}

fun selectAll() {
    val ic = currentInputConnection ?: return
    val extracted = ic.getExtractedText(ExtractedTextRequest(), 0)
    val text = extracted?.text ?: return
    ic.setSelection(0, text.length)
}

fun replaceSelection(replacement: String) {
    val ic = currentInputConnection ?: return
    ic.commitText(replacement, 1)
    // commitText replaces selected text automatically
}

9.6 — EditorInfo — Adapting to the Text Field

The EditorInfo object, received in onStartInput(), tells you everything about the target text field:

FieldWhat It Tells YouHow to Adapt
inputType & TYPE_MASK_CLASSText, number, phone, datetimeSwitch keyboard layout
inputType & TYPE_MASK_VARIATIONPassword, email, URI, person nameDisable prediction for passwords; show @ for email
inputType & TYPE_MASK_FLAGSAutocomplete, multiline, no suggestionsRespect TYPE_TEXT_FLAG_NO_SUGGESTIONS; enable multiline enter
imeOptions & IME_MASK_ACTIONDone, search, next, go, sendChange enter key label and icon
packageNameWhich app owns the text fieldPer-app quirk workarounds (some apps have broken InputConnections)
privateImeOptionsApp-specific hintsCustom integration (e.g., "com.example.disableGesture=true")
override fun onStartInput(info: EditorInfo, restarting: Boolean) {
    super.onStartInput(info, restarting)

    // Determine enter key appearance
    val enterLabel = when (info.imeOptions and EditorInfo.IME_MASK_ACTION) {
        EditorInfo.IME_ACTION_SEARCH -> "🔍"
        EditorInfo.IME_ACTION_SEND   -> "➤"
        EditorInfo.IME_ACTION_NEXT   -> "→"
        EditorInfo.IME_ACTION_GO     -> "Go"
        EditorInfo.IME_ACTION_DONE   -> "✓"
        else                         -> "↵"
    }
    keyboardView.setEnterKeyLabel(enterLabel)

    // Disable prediction for passwords
    val variation = info.inputType and InputType.TYPE_MASK_VARIATION
    val isPassword = variation == InputType.TYPE_TEXT_VARIATION_PASSWORD
            || variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
            || variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
    keyboardView.setPredictionEnabled(!isPassword)
}

9.7 — Candidate Selection for Accessibility API 37

Some languages compose text from pronunciation characters and then offer a list of conversion candidates. Android 17 adds a way for your IME to tell the editor that the user is in that ephemeral "choosing a candidate" state: TextAttribute.Builder.setTextSuggestionSelected(boolean). The editor can read the flag with TextAttribute.isTextSuggestionSelected(), which the reference describes as relevant for CJK languages. The attribute travels with the three-argument setComposingText() you already know from 9.3:

// API 37+: flag that the composing text is a conversion candidate the user is
// stepping through, so accessibility services do not announce it as a commit
fun showCandidate(ic: InputConnection, candidate: String) {
    if (Build.VERSION.SDK_INT >= 37) {
        val attribute = TextAttribute.Builder()
            .setTextSuggestionSelected(true)
            .build()
        ic.setComposingText(candidate, 1, attribute)   // 3-argument variant, API 33
    } else {
        ic.setComposingText(candidate, 1)
    }
}

When the user confirms a candidate, commit it as usual with commitText(). Setting the flag accurately is your whole job as the IME author. Whether it reaches a screen reader depends on the editor, and that is where the second half of the change lives (see Section 23, 23.5).

Who Needs This

Apps that target API 37 and use a standard TextView or EditText get the editor side automatically. Editors with a custom InputConnection must read the attribute and send the matching accessibility event themselves. If your keyboard serves only languages that commit text without a candidate-selection step, the flag changes nothing for you, but it costs nothing to set it correctly.

Section 10

Material You 3.0: The Complete Design System

Intermediate API 31+

Comprehensive coverage of Material You 3.0 and Material Design 3 Expressive for keyboard design, including dynamic color, theming tokens, motion system, and expressive UI patterns introduced in 2025-2026.

Understanding Material You Evolution

Material You represents Google's most significant design language evolution since the original Material Design in 2014. First introduced with Android 12 (API 31) in 2021, Material You has undergone continuous refinement, culminating in Material Design 3 Expressive announced at Google I/O 2025 and rolling out across Android 16 and Jetpack Compose (see the status note at the end of this section). The philosophy centers on three core principles:

  • Personalization: Interfaces adapt to user preferences, extracting color palettes from wallpapers and respecting system-wide theme choices.
  • Adaptability: Design tokens scale across device types (phones, tablets, foldables, desktops) and contexts (light mode, dark mode, high contrast).
  • Expressiveness: The 2025 "Expressive" update introduces bolder colors, springy motion, and emotional design patterns that make interfaces feel alive rather than static.

Material You Color System

The color system is Material You's most distinctive feature. Instead of designer-defined brand colors, Material You generates entire color palettes dynamically from the user's wallpaper. As of Material You 3.0 (API 34+), the Compose ColorScheme provides 36 color roles (expanded from 29 in earlier M3 versions, with 7 new surface container roles: surfaceBright, surfaceDim, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest, surfaceContainerLow, surfaceContainerLowest):

Color Role Purpose Light Mode Example Dark Mode Example
primary High-emphasis keys, primary actions #6750A4 #D0BCFF
onPrimary Text/icons on primary color #FFFFFF #381E72
primaryContainer Background for primary elements #EADDFF #4F378B
onPrimaryContainer Text on primaryContainer #21005D #EADDFF
secondary Medium-emphasis keys #625B71 #CCC2DC
tertiary Accent colors, special keys #7D5260 #EFB8C8
surface Keyboard background #FFFBFE #1C1B1F
surfaceVariant Alternative surface color #E7E0EC #49454F
surfaceTint Overlay for elevation
(API 34+)
#6750A4 #D0BCFF
outline Key borders #79747E #938F99
outlineVariant Subtle borders
(API 34+)
#CAC4D0 #49454F
surfaceBright Brighter surface (dark mode elevation)
(M3 1.2+)
#FFFBFE #3B3838
surfaceDim Dimmer surface (light mode depth)
(M3 1.2+)
#DED8E1 #131214
surfaceContainer Default container background
(M3 1.2+)
#F3EDF7 #211F26
surfaceContainerLow Keyboard background
(M3 1.2+)
#F7F2FA #1D1B20
surfaceContainerHigh Key face background
(M3 1.2+)
#ECE6F0 #2B2930
surfaceContainerHighest Pressed key background
(M3 1.2+)
#E6E0E9 #36343B
surfaceContainerLowest Lowest-emphasis surface
(M3 1.2+)
#FFFFFF #0F0D13

Implementing Dynamic Color in Your Keyboard

To adopt Material You dynamic color, use the DynamicColors API available on API 31+. This automatically extracts colors from the user's wallpaper and applies them to your keyboard:

// Compose implementation of Material You dynamic color
@Composable
fun KeyboardTheme(
    useDynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        // API 31+ supports dynamic color
        useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (isSystemInDarkTheme()) {
                dynamicDarkColorScheme(context)
            } else {
                dynamicLightColorScheme(context)
            }
        }
        // Fallback to static colors for API 30
        isSystemInDarkTheme() -> darkColorScheme(
            primary = Color(0xFFD0BCFF),
            onPrimary = Color(0xFF381E72),
            primaryContainer = Color(0xFF4F378B),
            onPrimaryContainer = Color(0xFFEADDFF),
            // ... full color scheme
        )
        else -> lightColorScheme(
            primary = Color(0xFF6750A4),
            onPrimary = Color(0xFFFFFFFF),
            primaryContainer = Color(0xFFEADDFF),
            onPrimaryContainer = Color(0xFF21005D),
            // ... full color scheme
        )
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = KeyboardTypography,
        content = content
    )
}

Using Color Roles Correctly

Material You's power comes from using semantic color roles instead of hardcoded colors. Here's how to apply color roles to keyboard keys:

@Composable
fun KeyboardKey(
    key: Key,
    modifier: Modifier = Modifier,
    onPress: (Key) -> Unit
) {
    val colorScheme = MaterialTheme.colorScheme

    // Determine color role based on key type
    val (backgroundColor, contentColor) = when (key.type) {
        KeyType.CHARACTER -> {
            // Standard letter keys use surface colors
            colorScheme.surfaceVariant to colorScheme.onSurface
        }
        KeyType.ACTION -> {
            // Action keys (Enter, Search) use primary
            colorScheme.primaryContainer to colorScheme.onPrimaryContainer
        }
        KeyType.MODIFIER -> {
            // Shift, Caps Lock use secondary
            colorScheme.secondaryContainer to colorScheme.onSecondaryContainer
        }
        KeyType.SPECIAL -> {
            // Emoji, Voice use tertiary for visual distinction
            colorScheme.tertiaryContainer to colorScheme.onTertiaryContainer
        }
    }

    Surface(
        modifier = modifier
            .padding(2.dp)
            .aspectRatio(1f),
        color = backgroundColor,
        contentColor = contentColor,
        shape = RoundedCornerShape(8.dp),
        tonalElevation = 1.dp, // Subtle elevation
        onClick = { onPress(key) }
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier.fillMaxSize()
        ) {
            Text(
                text = key.label,
                style = MaterialTheme.typography.bodyLarge,
                fontWeight = FontWeight.Medium
            )
        }
    }
}

Material 3 Expressive in Compose — Status as of October 2026

Material 3 Expressive is a design language, but Compose ships it in pieces, and not all of those pieces are stable. As of October 2026, androidx.compose.material3:material3 1.4.0 is the latest stable release and the Expressive work continues in the 1.5.0 alpha line (1.5.0-alpha29, released September 23, 2026). The 1.4.0 beta removed the APIs that were tagged ExperimentalMaterial3ExpressiveApi and told developers to switch to a 1.5.0 alpha to keep using them. A project on stable 1.4.0 therefore has the Material 3 color system and components described in this section, but not the Expressive ones.

The alpha release notes show the Expressive pieces graduating gradually rather than all at once. They list ButtonGroup, SplitButton, ToggleButtons, the FAB and FAB menu, the flexible top and bottom app bars, the floating toolbar, and wavy progress indicators as promoted out of the experimental opt-in, and the notes for 1.5.0-alpha18 also list the expressive theme (MaterialExpressiveTheme with expressiveLightColorScheme). Anything that has not graduated still requires the @ExperimentalMaterial3ExpressiveApi opt-in, so check the annotation in the API reference for the exact version you use.

Section 11

Dynamic Color Extraction & Integration

Intermediate API 31+

Extract wallpaper-derived colors from the system's DynamicColors API, apply them to keyboard surfaces, and handle graceful fallbacks on devices below API 31. Includes per-key color mapping strategies and contrast validation.

11.1 — How Dynamic Color Works

Starting with Android 12 (API 31), the system extracts a source color from the user's wallpaper, then generates a complete Material 3 color scheme through HCT (Hue-Chroma-Tone) color space transformations. This scheme is exposed through system resource overlays and the DynamicColors API.

The generated scheme contains five tonal palettes (Primary, Secondary, Tertiary, Neutral, Neutral-Variant), each with 13 tonal values (0–100). Your keyboard reads these as standard color resource tokens.

TokenUse in KeyboardsLight Mode ToneDark Mode Tone
colorSurfaceContainerLowKeyboard background9610
colorSurfaceContainerHighKey face background9217
colorSurfaceContainerHighestPressed key background9022
colorOnSurfaceKey label text1090
colorOnSurfaceVariantSecondary key labels (symbols)3080
colorPrimaryActive toggle keys (shift, language)4080
colorPrimaryContainerEnter key, special action keys9030
colorOnPrimaryContainerEnter key label/icon1090

11.2 — Applying Dynamic Colors in an IME

import com.google.android.material.color.DynamicColors
import com.google.android.material.color.MaterialColors

class KeyboardService : InputMethodService() {

    override fun onCreate() {
        super.onCreate()
        // Apply dynamic color overlay to the service's theme
        DynamicColors.applyToActivitiesIfAvailable(application)
    }

    override fun onCreateInputView(): View {
        // Wrap context with Material3 dynamic colors
        val themedContext = DynamicColors.wrapContextIfAvailable(this)

        return LayoutInflater.from(themedContext)
            .inflate(R.layout.keyboard_view, null)
    }
}

// Extracting individual colors programmatically
fun getKeyboardColors(context: Context): KeyboardColorScheme {
    return KeyboardColorScheme(
        background = MaterialColors.getColor(
            context, com.google.android.material.R.attr.colorSurfaceContainerLow, Color.WHITE
        ),
        keyFace = MaterialColors.getColor(
            context, com.google.android.material.R.attr.colorSurfaceContainerHigh, Color.LTGRAY
        ),
        keyLabel = MaterialColors.getColor(
            context, com.google.android.material.R.attr.colorOnSurface, Color.BLACK
        ),
        accentKey = MaterialColors.getColor(
            context, com.google.android.material.R.attr.colorPrimaryContainer, Color.BLUE
        ),
        accentLabel = MaterialColors.getColor(
            context, com.google.android.material.R.attr.colorOnPrimaryContainer, Color.WHITE
        ),
    )
}

data class KeyboardColorScheme(
    val background: Int,
    val keyFace: Int,
    val keyLabel: Int,
    val accentKey: Int,
    val accentLabel: Int,
)

11.3 — Fallback Strategy for Pre-API 31

Dynamic color is only available on API 31+. On older devices, fall back to a static Material 3 theme or your brand colors:

fun resolveKeyboardTheme(context: Context): KeyboardColorScheme {
    return if (DynamicColors.isDynamicColorAvailable()) {
        // Dynamic wallpaper-derived colors
        val dynamicContext = DynamicColors.wrapContextIfAvailable(context)
        getKeyboardColors(dynamicContext)
    } else {
        // Static fallback — Material baseline or brand colors
        KeyboardColorScheme(
            background = context.getColor(R.color.keyboard_bg_static),
            keyFace    = context.getColor(R.color.key_face_static),
            keyLabel   = context.getColor(R.color.key_label_static),
            accentKey  = context.getColor(R.color.accent_key_static),
            accentLabel = context.getColor(R.color.accent_label_static),
        )
    }
}

11.4 — Contrast Validation

Dynamic colors can occasionally produce low-contrast key labels. Always validate contrast ratios at runtime, especially for accessibility:

import androidx.core.graphics.ColorUtils

fun ensureMinimumContrast(
    foreground: Int,
    background: Int,
    minimumRatio: Double = 4.5,    // WCAG AA for normal text
): Int {
    val ratio = ColorUtils.calculateContrast(foreground, background)
    return if (ratio >= minimumRatio) {
        foreground
    } else {
        // Find the closest color in the same hue with sufficient contrast
        val hsl = FloatArray(3)
        ColorUtils.colorToHSL(foreground, hsl)
        // Adjust lightness up or down depending on background luminance
        val bgLuminance = ColorUtils.calculateLuminance(background)
        if (bgLuminance > 0.5) {
            // Dark text on light background — decrease lightness
            hsl[2] = (hsl[2] - 0.1f).coerceAtLeast(0f)
        } else {
            // Light text on dark background — increase lightness
            hsl[2] = (hsl[2] + 0.1f).coerceAtMost(1f)
        }
        ColorUtils.HSLToColor(hsl)
    }
}
Wallpaper Change Detection

When the user changes their wallpaper, the system triggers a configuration change. Your IME's onInitializeInterface() callback fires, which is the appropriate place to re-extract dynamic colors and refresh the keyboard appearance. You do not need a BroadcastReceiver for ACTION_WALLPAPER_CHANGED — the IME lifecycle handles this automatically.

Section 12

Theming System Architecture

Intermediate

Design a scalable theming system for keyboards: theme data models, real-time theme switching, custom user themes, theme persistence, and integrating static themes alongside Material You dynamic theming.

12.1 — Theme Data Model

A robust keyboard theme extends beyond colors to include shapes, typography, spacing, and effects. Define an immutable data class for the complete visual specification:

data class KeyboardTheme(
    val id: String,
    val name: String,
    val isDynamic: Boolean = false,

    // Color palette
    val colors: ThemeColors,

    // Shape & geometry
    val keyCornerRadius: Dp = 8.dp,
    val keyElevation: Dp = 2.dp,
    val keySpacingHorizontal: Dp = 4.dp,
    val keySpacingVertical: Dp = 6.dp,
    val keyboardPadding: PaddingValues = PaddingValues(horizontal = 3.dp, vertical = 4.dp),

    // Typography
    val letterTextSize: TextUnit = 22.sp,
    val symbolTextSize: TextUnit = 16.sp,
    val fontFamily: FontFamily = FontFamily.Default,
    val fontWeight: FontWeight = FontWeight.Medium,

    // Visual effects
    val keyPressedEffect: PressEffect = PressEffect.DARKEN,
    val borderWidth: Dp = 0.dp,
    val borderColor: Color = Color.Transparent,
    val shadowType: ShadowType = ShadowType.ELEVATION,
)

data class ThemeColors(
    val keyboardBackground: Color,
    val keyFace: Color,
    val keyFacePressed: Color,
    val keyLabel: Color,
    val keyLabelSecondary: Color,
    val accentKeyFace: Color,
    val accentKeyLabel: Color,
    val functionKeyFace: Color,
    val functionKeyLabel: Color,
    val popupBackground: Color,
    val popupText: Color,
    val suggestionBarBackground: Color,
    val suggestionText: Color,
    val suggestionTextHighlighted: Color,
)

enum class PressEffect { DARKEN, LIGHTEN, SCALE, RIPPLE, INVERT, NONE }
enum class ShadowType { ELEVATION, DROP, INNER, NONE }

12.2 — Theme Registry

object ThemeRegistry {
    private val themes = mutableMapOf<String, KeyboardTheme>()

    fun register(theme: KeyboardTheme) {
        themes[theme.id] = theme
    }

    fun get(id: String): KeyboardTheme? = themes[id]

    fun getAll(): List<KeyboardTheme> = themes.values.toList()

    init {
        // Built-in themes
        register(ClassicLightTheme())
        register(ClassicDarkTheme())
        register(HighContrastTheme())
        register(OceanBlueTheme())
        register(ForestGreenTheme())
        register(SunsetOrangeTheme())
    }
}

// Dynamic color theme — generated at runtime
fun createDynamicTheme(context: Context): KeyboardTheme {
    val colors = resolveKeyboardTheme(context)
    return KeyboardTheme(
        id = "dynamic_material_you",
        name = "Material You",
        isDynamic = true,
        colors = ThemeColors(
            keyboardBackground = Color(colors.background),
            keyFace = Color(colors.keyFace),
            keyFacePressed = Color(colors.keyFace).copy(alpha = 0.7f),
            keyLabel = Color(colors.keyLabel),
            keyLabelSecondary = Color(colors.keyLabel).copy(alpha = 0.7f),
            accentKeyFace = Color(colors.accentKey),
            accentKeyLabel = Color(colors.accentLabel),
            functionKeyFace = Color(colors.keyFace).copy(alpha = 0.8f),
            functionKeyLabel = Color(colors.keyLabel),
            popupBackground = Color(colors.keyFace),
            popupText = Color(colors.keyLabel),
            suggestionBarBackground = Color(colors.background),
            suggestionText = Color(colors.keyLabel),
            suggestionTextHighlighted = Color(colors.accentLabel),
        ),
    )
}

12.3 — Theme Persistence with DataStore

import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

class ThemePreferences(private val dataStore: DataStore<Preferences>) {
    companion object {
        private val SELECTED_THEME_KEY = stringPreferencesKey("selected_theme_id")
        private const val DEFAULT_THEME = "dynamic_material_you"
    }

    val selectedThemeId: Flow<String> = dataStore.data.map { prefs ->
        prefs[SELECTED_THEME_KEY] ?: DEFAULT_THEME
    }

    suspend fun setTheme(themeId: String) {
        dataStore.edit { prefs ->
            prefs[SELECTED_THEME_KEY] = themeId
        }
    }
}

12.4 — Real-Time Theme Switching

The keyboard must apply a new theme without closing and reopening. The pattern: observe the theme preference, rebuild colors, and propagate to the view:

// In KeyboardService
private val themeJob = SupervisorJob()
private val themeScope = CoroutineScope(Dispatchers.Main.immediate + themeJob)

override fun onCreate() {
    super.onCreate()
    themeScope.launch {
        themePreferences.selectedThemeId.collectLatest { themeId ->
            val theme = if (themeId == "dynamic_material_you") {
                createDynamicTheme(this@KeyboardService)
            } else {
                ThemeRegistry.get(themeId) ?: createDynamicTheme(this@KeyboardService)
            }
            keyboardView?.applyTheme(theme)
        }
    }
}

override fun onDestroy() {
    themeJob.cancel()
    super.onDestroy()
}
Theme Preview in Settings

Allow users to preview themes before committing. Show a miniature keyboard preview in your settings screen that responds immediately to theme selection. Use a lightweight Canvas composable that renders just 3 rows of simplified key shapes — enough to convey the visual style without building a full keyboard instance.

Section 13

Motion & Animations

Expert

Key press animations, layout transition choreography, spring physics, gesture trails, popup animations, and the Material Motion system — all within the frame budget of a real-time input surface.

13.1 — The Frame Budget Problem

At 60fps, each frame has a 16.67ms budget. A keyboard must complete touch processing, layout updates, animation calculations, and rendering within this window. At 120fps (common on flagship devices since 2023), the budget shrinks to 8.33ms. Every animation must be designed with this constraint.

OperationBudget at 60fpsBudget at 120fpsPriority
Touch event processing<2ms<1msCritical
Key state update<1ms<0.5msCritical
Animation calculation<2ms<1msHigh
Layout/measure<3ms<1.5msHigh
Canvas draw<6ms<3msMedium
GPU composition<2ms<1msSystem

13.2 — Key Press Animations (Custom View)

class KeyAnimator {
    // Per-key animation state — no allocation on each press
    private val keyAnimations = SparseArray<KeyAnimState>()

    data class KeyAnimState(
        var pressScale: Float = 1f,
        var pressAlpha: Float = 0f,
        var pressStartTime: Long = 0L,
        var isPressed: Boolean = false,
    )

    fun onKeyDown(keyIndex: Int) {
        val state = keyAnimations.getOrPut(keyIndex) { KeyAnimState() }
        state.isPressed = true
        state.pressStartTime = SystemClock.uptimeMillis()
    }

    fun onKeyUp(keyIndex: Int) {
        keyAnimations[keyIndex]?.isPressed = false
    }

    fun updateAnimation(keyIndex: Int): KeyAnimState {
        val state = keyAnimations[keyIndex] ?: return KeyAnimState()
        val elapsed = SystemClock.uptimeMillis() - state.pressStartTime

        if (state.isPressed) {
            // Press-in: fast ease-out to slightly smaller scale
            val progress = (elapsed / PRESS_DURATION_MS.toFloat()).coerceAtMost(1f)
            val eased = 1f - (1f - progress) * (1f - progress)    // Ease-out quad
            state.pressScale = 1f - (PRESS_SCALE_DELTA * eased)
            state.pressAlpha = PRESS_OVERLAY_ALPHA * eased
        } else {
            // Release: spring back
            val releaseProgress = (elapsed / RELEASE_DURATION_MS.toFloat()).coerceAtMost(1f)
            val springy = overshootInterpolator(releaseProgress)
            state.pressScale = (1f - PRESS_SCALE_DELTA) + (PRESS_SCALE_DELTA * springy)
            state.pressAlpha = PRESS_OVERLAY_ALPHA * (1f - releaseProgress)
        }
        return state
    }

    private fun overshootInterpolator(t: Float): Float {
        // Slight overshoot (1.02x) for tactile feel
        val s = 1.5f
        val t1 = t - 1f
        return t1 * t1 * ((s + 1f) * t1 + s) + 1f
    }

    companion object {
        const val PRESS_DURATION_MS = 50L      // Fast engage
        const val RELEASE_DURATION_MS = 120L   // Slower spring release
        const val PRESS_SCALE_DELTA = 0.04f    // 4% shrink
        const val PRESS_OVERLAY_ALPHA = 0.12f  // Subtle darkening
    }
}

13.3 — Compose Key Press Animation

@Composable
fun AnimatedKeyButton(
    definition: KeyDefinition,
    modifier: Modifier = Modifier,
    onPress: () -> Unit,
) {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()

    val scale by animateFloatAsState(
        targetValue = if (isPressed) 0.96f else 1f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessHigh,
        ),
        label = "keyScale",
    )

    val elevation by animateDpAsState(
        targetValue = if (isPressed) 0.dp else 3.dp,
        animationSpec = tween(durationMillis = if (isPressed) 40 else 100),
        label = "keyElevation",
    )

    Surface(
        modifier = modifier
            .height(52.dp)
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
            },
        onClick = onPress,
        interactionSource = interactionSource,
        shape = RoundedCornerShape(8.dp),
        color = MaterialTheme.colorScheme.surfaceContainerHigh,
        shadowElevation = elevation,
    ) {
        Box(contentAlignment = Alignment.Center) {
            Text(
                text = definition.label,
                style = MaterialTheme.typography.bodyLarge,
            )
        }
    }
}

13.4 — Layout Transition Animations

Switching between letter and symbol layouts should feel fluid. Use crossfade or shared-element transitions:

@Composable
fun AnimatedKeyboardContent(
    mode: KeyboardMode,
    onKeyPress: (Char) -> Unit,
) {
    AnimatedContent(
        targetState = mode,
        transitionSpec = {
            // Slide + fade for mode switches
            if (targetState == KeyboardMode.SYMBOLS_1) {
                slideInVertically { height -> height / 4 } + fadeIn(
                    animationSpec = tween(150)
                ) togetherWith slideOutVertically { height -> -height / 4 } + fadeOut(
                    animationSpec = tween(100)
                )
            } else {
                slideInVertically { height -> -height / 4 } + fadeIn(
                    animationSpec = tween(150)
                ) togetherWith slideOutVertically { height -> height / 4 } + fadeOut(
                    animationSpec = tween(100)
                )
            }
        },
        label = "keyboardModeTransition",
    ) { targetMode ->
        KeyboardLayout(mode = targetMode, onKeyPress = onKeyPress)
    }
}

13.5 — Key Popup Animations

Long-press popups (for accented characters, alternate symbols) should emerge from the key with a spring effect:

@Composable
fun KeyPopup(
    visible: Boolean,
    characters: List<Char>,
    anchorBounds: Rect,
    onSelect: (Char) -> Unit,
) {
    AnimatedVisibility(
        visible = visible,
        enter = scaleIn(
            initialScale = 0.7f,
            transformOrigin = TransformOrigin(0.5f, 1f),    // Scale from bottom-center
            animationSpec = spring(
                dampingRatio = Spring.DampingRatioLowBouncy,
                stiffness = Spring.StiffnessMediumLow,
            ),
        ) + fadeIn(tween(60)),
        exit = scaleOut(
            targetScale = 0.8f,
            animationSpec = tween(80),
        ) + fadeOut(tween(60)),
    ) {
        Surface(
            shape = RoundedCornerShape(12.dp),
            shadowElevation = 8.dp,
            color = MaterialTheme.colorScheme.surfaceContainerHighest,
        ) {
            Row(
                modifier = Modifier.padding(8.dp),
                horizontalArrangement = Arrangement.spacedBy(2.dp),
            ) {
                characters.forEach { char ->
                    PopupKeyItem(
                        character = char,
                        onSelect = { onSelect(char) },
                    )
                }
            }
        }
    }
}

13.6 — Gesture Trail Rendering

For swipe typing, render a smooth trail following the finger. Use a Path with quadratic Bézier curves for smoothness:

class GestureTrailRenderer {
    private val trailPath = Path()
    private val trailPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        strokeCap = Paint.Cap.ROUND
        strokeJoin = Paint.Join.ROUND
    }
    private val points = ArrayDeque<PointF>(64)
    private val maxPoints = 50

    fun addPoint(x: Float, y: Float) {
        points.addLast(PointF(x, y))
        if (points.size > maxPoints) points.removeFirst()
        rebuildPath()
    }

    fun clear() {
        points.clear()
        trailPath.reset()
    }

    fun draw(canvas: Canvas, color: Int, maxWidth: Float = 6f) {
        if (points.size < 2) return
        trailPaint.color = color

        // Variable-width stroke: thicker at recent points, thinner at old
        for (i in 1 until points.size) {
            val fraction = i.toFloat() / points.size
            trailPaint.strokeWidth = maxWidth * fraction
            trailPaint.alpha = (255 * fraction * fraction).toInt()
            canvas.drawLine(
                points[i - 1].x, points[i - 1].y,
                points[i].x, points[i].y,
                trailPaint,
            )
        }
    }

    private fun rebuildPath() {
        trailPath.reset()
        if (points.size < 2) return
        trailPath.moveTo(points[0].x, points[0].y)
        for (i in 1 until points.size) {
            val mid = PointF(
                (points[i - 1].x + points[i].x) / 2,
                (points[i - 1].y + points[i].y) / 2,
            )
            trailPath.quadTo(points[i - 1].x, points[i - 1].y, mid.x, mid.y)
        }
    }
}
Section 14

Dark Mode Best Practices for 2026

Intermediate

Modern dark mode implementation using dark gray (#1E1E1E) instead of pure black, off-white text for reduced eye strain, OLED optimization, and adaptive contrast ratios.

The Evolution of Dark Mode Design

Dark mode has evolved significantly since its mainstream adoption in 2019. Early implementations often used pure black (#000000) backgrounds with bright white (#FFFFFF) text, creating harsh contrast that caused eye strain and OLED screen burn-in. By 2026, industry best practices have converged on a more refined approach:

  • Dark gray surfaces: Use #1E1E1E (or similar dark gray) instead of pure black for the primary background. This reduces extreme contrast while maintaining OLED power savings.
  • Off-white text: Use #E1E1E1 or similar off-white instead of pure white for body text. This prevents glare and reduces eye fatigue during extended typing sessions.
  • Elevated surfaces: Layer lighter gray surfaces (#282828, #323232) on top of the base dark gray to create depth and hierarchy through elevation, not just shadows.
  • Contextual adaptation: Automatically adjust contrast based on ambient light conditions detected via device sensors.

Why Dark Gray Instead of Pure Black?

The shift from pure black to dark gray (#1E1E1E) is based on extensive UX research and user feedback throughout 2020-2025:

  1. Reduced Visual Fatigue: Pure black backgrounds with white text create maximum contrast (21:1 ratio), which sounds good on paper but causes eye strain in practice. The stark transition between black and white forces the iris to constantly adjust. Dark gray (#1E1E1E) with off-white text provides a more comfortable ~15:1 contrast ratio that still exceeds WCAG AAA standards.
  2. Better Elevation Perception: Material Design's elevation system uses subtle surface lightening to indicate component layering. On pure black, there's no room to go darker for recessed elements or lighter for elevated elements without jumping to gray. Starting at #1E1E1E provides headroom for both directions.
  3. OLED Burn-In Prevention: While OLED displays save power by turning off black pixels, pure black with sharp white UI elements creates high-contrast edges that accelerate pixel degradation. Dark gray reduces this differential while maintaining ~95% of the power savings.
  4. Ambient Light Compatibility: Pure black looks stunning in pitch-dark rooms but appears unnatural in environments with any ambient light. Dark gray maintains readability across a wider range of lighting conditions.

Implementing Professional Dark Mode

Here's a production-grade dark mode implementation for keyboards following 2026 best practices:

// Dark mode color palette (2026 standard)
object DarkModeColors {
    // Surface colors (dark grey, not black)
    val Surface = Color(0xFF1E1E1E)        // Base surface: #1E1E1E
    val SurfaceVariant = Color(0xFF282828) // Slightly elevated: #282828
    val SurfaceTint = Color(0xFF323232)    // More elevated: #323232

    // Text colors (off-white, not pure white)
    val OnSurface = Color(0xFFE1E1E1)      // Primary text: #E1E1E1
    val OnSurfaceVariant = Color(0xFFCAC4D0) // Secondary text
    val OnSurfaceDisabled = Color(0xFF938F99)  // Disabled state

    // Key colors for dark mode
    val KeyBackground = Color(0xFF282828)
    val KeyBackgroundPressed = Color(0xFF323232)
    val KeyLabel = Color(0xFFE1E1E1)

    // Action keys (slight color tint)
    val ActionKeyBackground = Color(0xFF2A2D3A)
    val ActionKeyLabel = Color(0xFF93C5FD)

    // Dividers and borders (very subtle)
    val Outline = Color(0xFF3E3E3E)
    val OutlineVariant = Color(0xFF323232)
}

@Composable
fun KeyboardDarkTheme(
    content: @Composable () -> Unit
) {
    val darkColorScheme = darkColorScheme(
        // Surface colors using dark grey
        surface = DarkModeColors.Surface,
        surfaceVariant = DarkModeColors.SurfaceVariant,
        surfaceTint = DarkModeColors.SurfaceTint,

        // Text colors using off-white
        onSurface = DarkModeColors.OnSurface,
        onSurfaceVariant = DarkModeColors.OnSurfaceVariant,

        // Primary, secondary, tertiary from Material You
        primary = Color(0xFFD0BCFF),
        onPrimary = Color(0xFF381E72),
        primaryContainer = Color(0xFF4F378B),
        onPrimaryContainer = Color(0xFFEADDFF),

        // Outline colors
        outline = DarkModeColors.Outline,
        outlineVariant = DarkModeColors.OutlineVariant,
    )

    MaterialTheme(
        colorScheme = darkColorScheme,
        typography = KeyboardTypography,
        content = content
    )
}

Key Rendering with Proper Dark Mode Colors

When rendering individual keys in dark mode, use elevated surfaces for keys and ensure text maintains WCAG AA contrast minimums (4.5:1 for normal text):

@Composable
fun DarkModeKey(
    label: String,
    modifier: Modifier = Modifier,
    isPressed: Boolean = false,
    onPress: () -> Unit
) {
    val colorScheme = MaterialTheme.colorScheme
    val isDarkMode = isSystemInDarkTheme()

    // Use elevated surface color for keys in dark mode
    val keyColor = when {
        !isDarkMode -> colorScheme.surfaceVariant
        isPressed -> DarkModeColors.KeyBackgroundPressed
        else -> DarkModeColors.KeyBackground
    }

    // Off-white text in dark mode
    val labelColor = if (isDarkMode) {
        DarkModeColors.KeyLabel
    } else {
        colorScheme.onSurface
    }

    Surface(
        modifier = modifier
            .padding(2.dp)
            .size(width = 48.dp, height = 54.dp),
        color = keyColor,
        shape = RoundedCornerShape(8.dp),
        // Subtle elevation in dark mode creates depth
        tonalElevation = if (isDarkMode) 2.dp else 1.dp,
        onClick = onPress
    ) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier.fillMaxSize()
        ) {
            Text(
                text = label,
                color = labelColor,
                style = MaterialTheme.typography.bodyLarge,
                fontWeight = FontWeight.Medium
            )
        }
    }
}

Testing Dark Mode Across Conditions

Professional keyboards test dark mode in multiple scenarios:

Condition Test Focus Pass Criteria
Pitch Black Room No glow/bloom around white text Off-white text (#E1E1E1) should not cause eye fatigue after 5 minutes of typing
Dim Indoor Lighting Key labels remain legible Contrast ratio ≥ 4.5:1 between key background and label
Bright Office Dark mode doesn't wash out Surface color remains visible, not appearing as black void
OLED Display Power consumption vs. pure black Dark gray (#1E1E1E) should consume ≤105% of pure black's power
LCD Display Backlight uniformity No visible backlight bleed on dark surfaces
Production Tip: Adaptive Brightness

Implement automatic contrast adjustment based on ambient light. In very dark environments (cinema, bedroom at night), reduce text color from #E1E1E1 to #C0C0C0 for even gentler contrast. In bright sunlight, increase surface brightness to #2A2A2A and text to #F0F0F0 for better outdoor visibility. This adaptive approach mirrors Apple's iOS keyboard behavior and significantly improves real-world usability.

Section 15

Adaptive Layouts & Responsive Design

Intermediate

Build keyboards that adapt to screen size, orientation, split-screen, foldable devices, and multi-window mode. Covers window size classes, hinge-aware layouts, one-handed mode, and tablet split keyboards.

15.1 — Detecting Display Configuration

Android provides several paths to detect the current display dimensions. In an IME, use WindowManager metrics rather than DisplayMetrics, which may report stale values:

import android.view.WindowManager
import android.view.WindowMetrics

fun getCurrentDisplayMetrics(service: InputMethodService): WindowMetrics {
    val wm = service.getSystemService(WindowManager::class.java)
    return wm.currentWindowMetrics
}

fun computeKeyboardDimensions(metrics: WindowMetrics): KeyboardDimensions {
    val bounds = metrics.bounds
    val screenWidth = bounds.width()
    val screenHeight = bounds.height()
    val density = Resources.getSystem().displayMetrics.density

    val isLandscape = screenWidth > screenHeight
    val isTablet = minOf(screenWidth, screenHeight) / density > 600f

    return when {
        isTablet && isLandscape -> KeyboardDimensions(
            width = (screenWidth * 0.65f).toInt(),    // Split keyboard
            rowHeight = (52 * density).toInt(),
            keyGap = (5 * density).toInt(),
        )
        isTablet -> KeyboardDimensions(
            width = screenWidth,
            rowHeight = (56 * density).toInt(),
            keyGap = (5 * density).toInt(),
        )
        isLandscape -> KeyboardDimensions(
            width = screenWidth,
            rowHeight = (40 * density).toInt(),        // Shorter rows for landscape
            keyGap = (3 * density).toInt(),
        )
        else -> KeyboardDimensions(
            width = screenWidth,
            rowHeight = (52 * density).toInt(),
            keyGap = (4 * density).toInt(),
        )
    }
}

data class KeyboardDimensions(
    val width: Int,
    val rowHeight: Int,
    val keyGap: Int,
)

15.2 — Foldable Device Support

Use Jetpack WindowManager to detect foldable posture and hinge position:

import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker

// In your Service or Activity
private fun observeFoldingState() {
    lifecycleScope.launch {
        WindowInfoTracker.getOrCreate(this@KeyboardService)
            .windowLayoutInfo(this@KeyboardService)
            .collect { layoutInfo ->
                val foldingFeature = layoutInfo.displayFeatures
                    .filterIsInstance<FoldingFeature>()
                    .firstOrNull()

                when {
                    foldingFeature == null -> applyStandardLayout()
                    foldingFeature.state == FoldingFeature.State.HALF_OPENED
                        && foldingFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
                    -> applyTabletopLayout(foldingFeature.bounds)
                    else -> applyStandardLayout()
                }
            }
    }
}

15.3 — One-Handed Mode

enum class OneHandedSide { LEFT, RIGHT, DISABLED }

fun applyOneHandedMode(
    keyboardView: View,
    side: OneHandedSide,
    screenWidth: Int,
) {
    val lp = keyboardView.layoutParams as FrameLayout.LayoutParams
    val keyboardWidthFraction = 0.78f

    when (side) {
        OneHandedSide.LEFT -> {
            lp.width = (screenWidth * keyboardWidthFraction).toInt()
            lp.gravity = Gravity.START or Gravity.BOTTOM
        }
        OneHandedSide.RIGHT -> {
            lp.width = (screenWidth * keyboardWidthFraction).toInt()
            lp.gravity = Gravity.END or Gravity.BOTTOM
        }
        OneHandedSide.DISABLED -> {
            lp.width = ViewGroup.LayoutParams.MATCH_PARENT
            lp.gravity = Gravity.BOTTOM
        }
    }
    keyboardView.layoutParams = lp
    keyboardView.requestLayout()
}

15.4 — Split Keyboard for Tablets

Tablets wider than 600dp benefit from a split keyboard where keys cluster near the thumbs. The center gap should account for the device's physical center:

data class SplitKeyboardConfig(
    val leftKeys: List<KeyDefinition>,
    val rightKeys: List<KeyDefinition>,
    val centerGapDp: Float = 48f,
)

fun splitQwertyRow(row: List<KeyDefinition>): SplitKeyboardConfig {
    val midpoint = row.size / 2
    return SplitKeyboardConfig(
        leftKeys = row.subList(0, midpoint),
        rightKeys = row.subList(midpoint, row.size),
    )
}
Multi-Window Configuration Changes

When the app enters split-screen or picture-in-picture mode, the IME receives onConfigurationChanged(). Use this callback to recalculate keyboard dimensions with the new window bounds. Do not cache display metrics at startup — always query current metrics during configuration changes.

15.5 — API 37: Large-Screen Enforcement API 37

Android 16 began ignoring orientation, resizability, and aspect-ratio restrictions on large screens and offered a temporary developer opt-out. Android 17 removes that opt-out. For apps targeting API 37 that run on displays with a smallest width of 600 dp or more, attributes such as screenOrientation, resizeableActivity, minAspectRatio, and maxAspectRatio are ignored, and the app fills the whole display window without pillarboxing. Games (declared with android:appCategory), users who explicitly opt in to an app's default behavior in the aspect-ratio settings, and screens below 600 dp are exempt.

Your keyboard window already follows the display, because 15.1 reads WindowManager metrics instead of assuming a shape. What changes is everything around it: the activities you ship (settings, onboarding, a dictionary editor) and the host apps that show your keyboard no longer hold a fixed orientation or size on tablets and foldables. Plan for:

  • More configuration changes: Windows can be resized or rotated at any moment, so recompute dimensions in onConfigurationChanged() instead of caching them.
  • Unfamiliar aspect ratios: A host window can be much wider or much shorter than the device. Derive layout from the current window bounds, never from the device type.
  • No locked orientation in your own screens: A screenOrientation setting on a settings activity stops working on large screens when you target API 37, so make those screens adaptive too.
  • Keyboard reappearance after resize: A resize that the host does not handle can leave the keyboard closed, as described in Section 05 (5.7).
Section 16

Haptic Feedback

Intermediate

Implement responsive, accessible haptic feedback for key presses using the Android Vibrator API and HapticFeedbackConstants. Covers API-level selection, custom waveforms, and respecting system haptic settings.

16.1 — Haptic Feedback Strategy

EventAPI 30+API 33+Fallback
Key press (letter)KEYBOARD_TAPKEYBOARD_TAPperformHapticFeedback(VIRTUAL_KEY)
Key press (delete)KEYBOARD_TAPSEGMENT_FREQUENT_TICKperformHapticFeedback(LONG_PRESS)
Long press popupLONG_PRESSLONG_PRESS50ms vibration
Shift toggleCONFIRMCONFIRMperformHapticFeedback(VIRTUAL_KEY)
Gesture word confirmedVibrationEffect (40ms)GESTURE_END40ms vibration
Error (invalid input)REJECTREJECT100ms vibration

16.2 — Implementation

import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.view.HapticFeedbackConstants

class KeyboardHaptics(private val service: InputMethodService) {
    private val vibrator: Vibrator = if (Build.VERSION.SDK_INT >= 31) {
        val mgr = service.getSystemService(VibratorManager::class.java)
        mgr.defaultVibrator
    } else {
        @Suppress("DEPRECATION")
        service.getSystemService(Vibrator::class.java)
    }

    private val isHapticEnabled: Boolean
        get() = Settings.System.getInt(
            service.contentResolver,
            Settings.System.HAPTIC_FEEDBACK_ENABLED, 1,
        ) == 1

    fun onKeyPress(view: View) {
        if (!isHapticEnabled) return
        view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
    }

    fun onLongPress(view: View) {
        if (!isHapticEnabled) return
        view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
    }

    fun onGestureWordConfirmed() {
        if (!isHapticEnabled) return
        if (Build.VERSION.SDK_INT >= 33) {
            vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
        } else if (Build.VERSION.SDK_INT >= 26) {
            vibrator.vibrate(
                VibrationEffect.createOneShot(30, VibrationEffect.DEFAULT_AMPLITUDE)
            )
        } else {
            @Suppress("DEPRECATION")
            vibrator.vibrate(30)
        }
    }

    fun onError() {
        if (!isHapticEnabled) return
        if (Build.VERSION.SDK_INT >= 30) {
            vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_DOUBLE_CLICK))
        }
    }
}

16.3 — Android 16: Richer Haptic Curves API 36

Android 16 introduces amplitude and frequency curves via VibrationEffect.BasicEnvelopeBuilder (simplified) and VibrationEffect.WaveformEnvelopeBuilder (advanced), replacing the binary on/off waveform patterns with smooth, device-adaptive haptic envelopes. The system translates your curves to the optimal representation for each device's haptic actuator (LRA, ERM, or piezo), so you design the intended sensation rather than raw motor timings.

// Android 16+: Expressive haptic curves for keyboard events
// BasicEnvelopeBuilder: intensity (0–1) + sharpness (0–1) + durationMs
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
class RichKeyboardHaptics(private val service: InputMethodService) {
    private val vibrator: Vibrator = service.getSystemService(VibratorManager::class.java)
        .defaultVibrator

    /** Soft, natural key tap with attack–sustain–release envelope. */
    fun onKeyTap() {
        vibrator.vibrate(
            VibrationEffect.BasicEnvelopeBuilder()
                .addControlPoint(0.75f, 0.9f, 4)   // Sharp attack — crisp onset
                .addControlPoint(0.4f, 0.6f, 8)   // Brief sustain at medium intensity
                .addControlPoint(0.0f, 0.4f, 12)   // Smooth release — natural fade
                .build()
        )
    }

    /** Stronger haptic for spacebar and enter — "thud" texture. */
    fun onActionKey() {
        vibrator.vibrate(
            VibrationEffect.BasicEnvelopeBuilder()
                .addControlPoint(1.0f, 0.3f, 6)    // Full-intensity attack, low sharpness = "thud"
                .addControlPoint(0.6f, 0.3f, 15)   // Longer sustain for weight
                .addControlPoint(0.0f, 0.2f, 20)   // Slow release
                .build()
        )
    }

    /** Double-pulse error pattern — unmistakable rejection feedback. */
    fun onError() {
        vibrator.vibrate(
            VibrationEffect.BasicEnvelopeBuilder()
                .addControlPoint(1.0f, 1.0f, 6)    // First pulse — sharp
                .addControlPoint(0.0f, 0.5f, 8)    // Quick fade
                .addControlPoint(0.0f, 0.5f, 35)   // Pause between pulses
                .addControlPoint(1.0f, 1.0f, 6)    // Second pulse
                .addControlPoint(0.0f, 0.5f, 10)   // Fade out
                .build()
        )
    }

    /** Gentle tick for gesture trail waypoints. */
    fun onGestureWaypoint() {
        vibrator.vibrate(
            VibrationEffect.BasicEnvelopeBuilder()
                .addControlPoint(0.3f, 0.8f, 3)    // Light, sharp tick
                .addControlPoint(0.0f, 0.5f, 6)    // Quick release
                .build()
        )
    }
}
Graceful Degradation Strategy

Wrap rich haptic curves in an if (Build.VERSION.SDK_INT >= 36) check and fall back to the VibrationEffect.createPredefined() patterns from Section 16.2 on older devices. The system silently degrades curves to the closest supported effect on devices with simpler actuators, but explicit fallbacks give you full control over the pre-API 36 experience. See also the API 36 haptic curves overview in Section 2.

Section 17

Sound Effects

Beginner

Add responsive audio feedback (key clicks, modifiers, deletion) using AudioManager system sounds and custom SoundPool assets. Covers volume scaling, system setting compliance, and sound-haptic synchronization.

17.1 — System Key Click Sounds

The simplest approach uses the system's built-in key click through AudioManager. This automatically respects the user's system sound preferences:

import android.media.AudioManager

class KeyboardSounds(private val service: InputMethodService) {
    private val audioManager = service.getSystemService(AudioManager::class.java)

    fun playKeyClick() {
        audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_STANDARD, -1f)
    }

    fun playDeleteClick() {
        audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_DELETE, -1f)
    }

    fun playReturnClick() {
        audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_RETURN, -1f)
    }

    fun playSpacebar() {
        audioManager.playSoundEffect(AudioManager.FX_KEYPRESS_SPACEBAR, -1f)
    }
}

17.2 — Custom Sound Pool

For branded audio experiences, load custom sound assets with SoundPool:

import android.media.SoundPool
import android.media.AudioAttributes

class CustomKeyboardSounds(private val context: Context) {
    private val soundPool: SoundPool
    private val sounds = mutableMapOf<String, Int>()

    init {
        val attrs = AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
            .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
            .build()

        soundPool = SoundPool.Builder()
            .setMaxStreams(4)    // Up to 4 simultaneous sounds
            .setAudioAttributes(attrs)
            .build()

        // Pre-load sounds from raw resources
        sounds["tap"]    = soundPool.load(context, R.raw.key_tap, 1)
        sounds["delete"] = soundPool.load(context, R.raw.key_delete, 1)
        sounds["return"] = soundPool.load(context, R.raw.key_return, 1)
        sounds["space"]  = soundPool.load(context, R.raw.key_space, 1)
    }

    fun play(soundName: String, volumeScale: Float = 0.6f) {
        val id = sounds[soundName] ?: return
        soundPool.play(id, volumeScale, volumeScale, 1, 0, 1f)
    }

    fun release() {
        soundPool.release()
    }
}
Sound & Haptic Synchronization

When both sound and haptic feedback are enabled, trigger them simultaneously in the same callback. Android's audio pipeline adds approximately 10-30ms latency for sound playback, while haptic feedback is near-instant. This small offset is imperceptible to users. Do not add artificial delays to synchronize them — it will make both feel sluggish.

Section 18

Gesture Typing (Swipe Input)

Expert

Implement swipe-to-type input: path recording, key proximity detection, candidate word generation, dictionary integration, and the gesture trail rendering pipeline. Also covers stylus handwriting support added in API 33.

18.1 — Gesture Recognition Pipeline

StageInputOutputTime Budget
1. Path captureMotionEvent streamList<PointF> (decimated)<0.5ms per event
2. Key proximityPath points + key layoutList<List<Key>> (near keys per point)<2ms total
3. Segment extractionProximity matrixSequence of key groups<1ms
4. Dictionary lookupKey sequencesTop-N word candidates<15ms
5. RankingCandidates + path shapeSorted results with scores<3ms
6. CommitBest candidateText to InputConnection<0.5ms

18.2 — Path Recording

class GesturePathRecorder {
    private val rawPoints = mutableListOf<GesturePoint>()
    private val decimatedPoints = mutableListOf<GesturePoint>()

    data class GesturePoint(
        val x: Float,
        val y: Float,
        val timestamp: Long,
    )

    private val minPointDistanceSq = 9f    // 3px minimum between sampled points

    fun start(x: Float, y: Float, time: Long) {
        rawPoints.clear()
        decimatedPoints.clear()
        addPoint(x, y, time)
    }

    fun addPoint(x: Float, y: Float, time: Long) {
        val point = GesturePoint(x, y, time)
        rawPoints.add(point)

        // Decimate: skip points too close to previous
        val last = decimatedPoints.lastOrNull()
        if (last == null || distanceSq(last.x, last.y, x, y) >= minPointDistanceSq) {
            decimatedPoints.add(point)
        }
    }

    fun getDecimatedPath(): List<GesturePoint> = decimatedPoints.toList()

    private fun distanceSq(x1: Float, y1: Float, x2: Float, y2: Float): Float {
        val dx = x2 - x1
        val dy = y2 - y1
        return dx * dx + dy * dy
    }
}

18.3 — Key Proximity Detection

class KeyProximityResolver(
    private val keys: List<KeyDefinition>,
    private val proximityRadiusPx: Float,
) {
    /**
     * For each gesture point, find all keys within proximity radius.
     * Returns a list of key-sets — one per decimated path point.
     */
    fun resolveProximity(path: List<GesturePoint>): List<Set<KeyDefinition>> {
        val radiusSq = proximityRadiusPx * proximityRadiusPx

        return path.map { point ->
            keys.filter { key ->
                val cx = key.x + key.width / 2f
                val cy = key.y + key.height / 2f
                val dx = point.x - cx
                val dy = point.y - cy
                (dx * dx + dy * dy) <= radiusSq
            }.toSet()
        }
    }
}

18.4 — Stylus Handwriting (API 33+)

Android 13 introduced an IME handwriting API for stylus input. The system handles ink rendering; the IME provides recognition:

class HandwritingKeyboardService : InputMethodService() {

    override fun onStartStylusHandwriting(): Boolean {
        // Return true to accept handwriting sessions
        return true
    }

    override fun onStylusHandwritingMotionEvent(event: MotionEvent): Boolean {
        when (event.actionMasked) {
            MotionEvent.ACTION_DOWN -> beginStroke(event)
            MotionEvent.ACTION_MOVE -> continueStroke(event)
            MotionEvent.ACTION_UP -> {
                endStroke(event)
                recognizeAndCommit()
            }
        }
        return true
    }

    override fun onFinishStylusHandwriting() {
        // Clean up recognition state
        clearStrokes()
    }

    // API 35 adds connectionless stylus handwriting
    // for hover-to-write without tapping into a text field
    override fun onStartConnectionlessStylusHandwriting(
        inputType: Int,
        cursorAnchorInfo: CursorAnchorInfo?,
    ): Boolean {
        return true    // Accept connectionless sessions
    }
}
Dictionary Requirements

Gesture typing quality depends almost entirely on the dictionary. A production gesture keyboard needs a frequency-weighted lexicon of 50,000–200,000 words per language, typically compressed into a trie or DAWG structure. Open-source dictionaries are available from projects like the HeliBoard AOSP dictionaries and the Leipzig Corpora Collection. See Section 27 for a curated list.

Section 19

Autocorrect & Text Prediction

Expert

Build a suggestion strip with auto-correction, next-word prediction, and user dictionary learning. Covers edit-distance scoring, language model integration, suggestion UI, and respecting editor flags.

19.1 — Suggestion Architecture

interface SuggestionProvider {
    /** Return ranked suggestions for the current composing word. */
    fun getSuggestions(
        composingWord: String,
        precedingText: String,
        maxResults: Int = 5,
    ): List<Suggestion>

    /** Return next-word predictions (no composing word). */
    fun getPredictions(
        precedingText: String,
        maxResults: Int = 3,
    ): List<Suggestion>
}

data class Suggestion(
    val word: String,
    val score: Float,            // 0.0–1.0 confidence
    val isAutoCorrect: Boolean,  // Should replace typed text automatically
    val source: SuggestionSource,
)

enum class SuggestionSource {
    DICTIONARY,        // Static lexicon match
    LANGUAGE_MODEL,    // N-gram or neural prediction
    USER_DICTIONARY,   // Learned from user typing
    CONTACTS,          // Contact name matches
    CLIPBOARD,         // Recent clipboard content
}

19.2 — Edit Distance Scoring

/**
 * Weighted Damerau-Levenshtein distance with keyboard layout awareness.
 * Adjacent key substitutions cost less than distant key substitutions.
 */
fun keyboardAwareEditDistance(
    typed: String,
    candidate: String,
    keyDistanceMap: Map<Pair<Char, Char>, Float>,
): Float {
    val n = typed.length
    val m = candidate.length
    val d = Array(n + 1) { FloatArray(m + 1) }

    for (i in 0..n) d[i][0] = i.toFloat()
    for (j in 0..m) d[0][j] = j.toFloat()

    for (i in 1..n) {
        for (j in 1..m) {
            val cost = if (typed[i - 1] == candidate[j - 1]) {
                0f
            } else {
                // Adjacent keys on keyboard layout cost less
                val pair = Pair(
                    typed[i - 1].lowercaseChar(),
                    candidate[j - 1].lowercaseChar(),
                )
                keyDistanceMap[pair] ?: 1f
            }

            d[i][j] = minOf(
                d[i - 1][j] + 1f,           // Deletion
                d[i][j - 1] + 1f,           // Insertion
                d[i - 1][j - 1] + cost,     // Substitution (distance-weighted)
            )

            // Transposition
            if (i > 1 && j > 1
                && typed[i - 1] == candidate[j - 2]
                && typed[i - 2] == candidate[j - 1]
            ) {
                d[i][j] = minOf(d[i][j], d[i - 2][j - 2] + 0.5f)
            }
        }
    }
    return d[n][m]
}

19.3 — Suggestion Strip UI

@Composable
fun SuggestionStrip(
    suggestions: List<Suggestion>,
    onSuggestionSelected: (Suggestion) -> Unit,
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(40.dp)
            .background(MaterialTheme.colorScheme.surfaceContainerLow),
        horizontalArrangement = Arrangement.SpaceEvenly,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        suggestions.forEachIndexed { index, suggestion ->
            if (index > 0) {
                VerticalDivider(
                    modifier = Modifier
                        .height(20.dp)
                        .width(1.dp),
                    color = MaterialTheme.colorScheme.outlineVariant,
                )
            }
            Text(
                text = suggestion.word,
                modifier = Modifier
                    .weight(1f)
                    .clickable { onSuggestionSelected(suggestion) }
                    .padding(horizontal = 8.dp, vertical = 8.dp),
                color = if (suggestion.isAutoCorrect)
                    MaterialTheme.colorScheme.primary
                else MaterialTheme.colorScheme.onSurface,
                fontWeight = if (suggestion.isAutoCorrect)
                    FontWeight.Bold
                else FontWeight.Normal,
                textAlign = TextAlign.Center,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

19.4 — Respecting Editor Flags

Some text fields disable autocorrect. Always check EditorInfo flags:

fun shouldOfferSuggestions(editorInfo: EditorInfo): Boolean {
    // TYPE_TEXT_FLAG_NO_SUGGESTIONS explicitly disables suggestions
    if (editorInfo.inputType and InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS != 0) return false

    // Password fields — never suggest
    val variation = editorInfo.inputType and InputType.TYPE_MASK_VARIATION
    if (variation == InputType.TYPE_TEXT_VARIATION_PASSWORD ||
        variation == InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD ||
        variation == InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD
    ) return false

    // Auto-correct flag is the positive signal
    val autoCorrect = editorInfo.inputType and InputType.TYPE_TEXT_FLAG_AUTO_CORRECT != 0
    return autoCorrect || variation == InputType.TYPE_TEXT_VARIATION_NORMAL
}

fun shouldAutoCorrect(editorInfo: EditorInfo): Boolean {
    return editorInfo.inputType and InputType.TYPE_TEXT_FLAG_AUTO_CORRECT != 0
}

19.5 — On-Device Language Models

The dictionary and edit-distance approach in 19.1 and 19.2 answers one question: which words are near what the user typed? A language model answers a different one: which word is likely here, given the words before it? The SuggestionSource.LANGUAGE_MODEL value from 19.1 already leaves room for that tier, and in 2026 it increasingly means a small transformer running on the device.

What Shipping Keyboards Do

ProductOn-Device ModelWhat to Know
FUTO KeyboardTransformer predictions and autocorrect through llama.cppEnglish only as of October 2026, according to the project documentation. A strength setting blends the result between AOSP-style and neural behavior, the model can be turned off on slower devices, and the personal dictionary is fed to the model.
Gboard Writing ToolsProofread and rephrase with on-device Gemini NanoDebuted on Pixel 10; press coverage reports a rollout to other phones that have a supported Gemini Nano version. It is a Gboard feature, not an API you can call from your own IME.
ML Kit GenAI Proofreading and RewritingThe same capability for third-party apps, through AICore and Gemini NanoBeta, aimed at short text and chat messages. Proofreading takes an input type of KEYBOARD or VOICE. Not supported on devices with an unlocked bootloader, and availability varies by device, so check the feature status first.

How It Fits Your Dictionary Pipeline

A language model does not replace the dictionary and trie from earlier in this chapter. The dictionary is fast, tiny, and predictable, so it stays the always-on tier that generates candidates. A model earns its cost in two places: re-ranking the top few candidates using the preceding words, and on-request actions such as proofreading or rewriting a finished message. Both cost memory and latency, so gate them the same way you gate any heavy work: check thermal headroom (Section 22, 22.5), budget model memory against the limits in 22.6, and fall back to the dictionary tier when either is tight.

// ML Kit GenAI Proofreading (beta): an on-demand action, never a per-keystroke call
class ProofreadAction(context: Context) {
    private val proofreader = Proofreading.getClient(
        ProofreaderOptions.builder(context)
            .setInputType(ProofreaderOptions.InputType.KEYBOARD)   // VOICE for dictated text
            .setLanguage(ProofreaderOptions.Language.ENGLISH)
            .build()
    )

    suspend fun proofread(text: String) {
        // Status is UNAVAILABLE, DOWNLOADABLE, DOWNLOADING, or AVAILABLE
        if (proofreader.checkFeatureStatus().await() != FeatureStatus.AVAILABLE) {
            return   // fall back to the dictionary pipeline from 19.1
        }
        val request = ProofreadingRequest.builder(text).build()
        val results = proofreader.runInference(request).await().results
        showSuggestions(results)   // sorted by descending confidence
    }

    fun release() = proofreader.close()
}

The dependency for this call is com.google.mlkit:genai-proofreading:1.0.0-beta1, and the API requires Android API level 26 or higher. Because the feature depends on AICore and Gemini Nano, treat it as an optional enhancement on supported devices, not as part of your baseline suggestion path.

Personalization Stays on the Device

On-device models keep typed text out of any server, which is the privacy argument from Section 01 made concrete. The rules from 19.4 apply with more force: never feed password fields, credit card fields, or fields marked TYPE_TEXT_FLAG_NO_SUGGESTIONS into a prompt, a personal dictionary, or any fine-tuning step, and let users see and reset what has been learned. Google's published research on Gboard explores federated learning, which trains a next-word model across many devices without exporting typed text, and later work combines it with synthetic data. Those papers describe a training method, not an API for third-party keyboards, so most independent keyboards personalize with an on-device user dictionary and ranking weights instead.

Section 20

Voice Input Integration

Expert

Add speech-to-text input to your keyboard using Android's SpeechRecognizer API and on-device recognition models. Covers microphone permission flow, streaming recognition, voice command handling, and the IME-specific lifecycle constraints.

20.1 — Architecture Overview

Voice input in an IME is constrained by two factors: (1) the keyboard runs as a service, not an activity, so you cannot launch RecognizerIntent directly; and (2) microphone access requires RECORD_AUDIO permission, which must be granted through an activity context. The recommended architecture:

ComponentResponsibilityRuns In
Permission ActivityRequest RECORD_AUDIO on first useTransparent Activity
SpeechRecognizer wrapperManage recognizer lifecycleIME Service (main thread)
Voice UI overlayShow microphone state, waveform, partial resultsKeyboard view layer
Text committerRoute recognized text to InputConnectionIME Service

20.2 — SpeechRecognizer in an IME

import android.speech.RecognitionListener
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer

class VoiceInputController(private val service: InputMethodService) {
    private var recognizer: SpeechRecognizer? = null

    fun startListening(languageCode: String = "en-US") {
        // SpeechRecognizer MUST be created and used on the main thread
        recognizer?.destroy()
        recognizer = SpeechRecognizer.createSpeechRecognizer(service).apply {
            setRecognitionListener(recognitionListener)
        }

        val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
            putExtra(
                RecognizerIntent.EXTRA_LANGUAGE_MODEL,
                RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
            )
            putExtra(RecognizerIntent.EXTRA_LANGUAGE, languageCode)
            putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
            putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
            // API 33+: let the recognizer detect the spoken language
            if (Build.VERSION.SDK_INT >= 33) {
                putExtra(RecognizerIntent.EXTRA_ENABLE_LANGUAGE_DETECTION, true)
            }
        }
        recognizer?.startListening(intent)
    }

    fun stopListening() {
        recognizer?.stopListening()
    }

    fun destroy() {
        recognizer?.destroy()
        recognizer = null
    }

    private val recognitionListener = object : RecognitionListener {
        override fun onResults(results: Bundle?) {
            val matches = results
                ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
                ?: return
            val bestResult = matches.firstOrNull() ?: return
            service.currentInputConnection?.commitText(bestResult, 1)
        }

        override fun onPartialResults(partialResults: Bundle?) {
            val partial = partialResults
                ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
                ?.firstOrNull() ?: return
            // Show partial text in suggestion strip or composing text
            service.currentInputConnection?.setComposingText(partial, 1)
        }

        override fun onError(error: Int) {
            val message = when (error) {
                SpeechRecognizer.ERROR_NO_MATCH -> "No speech detected"
                SpeechRecognizer.ERROR_NETWORK -> "Network unavailable"
                SpeechRecognizer.ERROR_AUDIO -> "Audio recording error"
                SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Microphone permission required"
                else -> "Recognition error: $error"
            }
            // Show error in voice UI overlay
        }

        override fun onReadyForSpeech(params: Bundle?) {}
        override fun onBeginningOfSpeech() {}
        override fun onRmsChanged(rmsdB: Float) {}
        override fun onBufferReceived(buffer: ByteArray?) {}
        override fun onEndOfSpeech() {}
        override fun onEvent(eventType: Int, params: Bundle?) {}
    }
}

20.3 — On-Device Recognition (API 33+)

Starting with API 33, you can check for and trigger on-device speech recognition, which works offline with lower latency:

fun createRecognizer(context: Context): SpeechRecognizer {
    return if (Build.VERSION.SDK_INT >= 33 &&
        SpeechRecognizer.isOnDeviceRecognitionAvailable(context)
    ) {
        SpeechRecognizer.createOnDeviceSpeechRecognizer(context)
    } else {
        SpeechRecognizer.createSpeechRecognizer(context)
    }
}

20.4 — Voice Subtype Declaration

Declare a voice input subtype in your IME's method.xml so the system knows your keyboard supports voice:

<input-method xmlns:android="http://schemas.android.com/apk/res/android"
    android:settingsActivity=".ui.SettingsActivity">

    <subtype
        android:imeSubtypeMode="keyboard"
        android:languageTag="en-US"
        android:label="@string/subtype_en_keyboard" />

    <subtype
        android:imeSubtypeMode="voice"
        android:languageTag="en-US"
        android:label="@string/subtype_en_voice"
        android:overridesImplicitlyEnabledSubtype="false" />
</input-method>

20.5 — Choosing a Recognition Path

The platform SpeechRecognizer is no longer the only first-party option, and bundling your own model is no longer the only way to stay offline. Pick the path before you write the integration:

OptionWhere It RunsNotes
Platform SpeechRecognizer (20.2, 20.3)Device or cloud, depending on the recognizer serviceUse createOnDeviceSpeechRecognizer() from API 33 when offline operation matters.
ML Kit GenAI Speech RecognitionOn device. Basic mode uses the traditional on-device model; Advanced mode uses Gemini NanoAlpha (com.google.mlkit:genai-speech-recognition:1.0.0-alpha1), with no SLA or deprecation policy. Basic mode targets most devices on API 31 and higher; Advanced mode is available on Pixel 10 and Pixel 11. Not supported with an unlocked bootloader. Results stream as partial text that becomes final.
Bundled model (for example whisper.cpp)Your own processFull control over models and languages, at the cost of model size and the memory limits in Section 22. The Mobile STT Engineering Guide covers model choice, VAD, and streaming decoders.
Hand-off to a voice-input appA separate on-device appKeeps the model out of your IME process. FUTO Voice Input runs entirely on device and is reported to work with HeliBoard, FlorisBoard, and AnySoftKeyboard.

For ML Kit, the documentation recommends AudioSource.fromMic() because it handles the audio format for you. If you pass your own file descriptor instead, the audio must be raw 16-bit PCM, mono, at 16 kHz, delivered at real-time rate; file-backed descriptors that read at full speed are not supported. Call the feature-status check before you show any related UI, because the model can be unavailable or still downloading.

FUTO Keyboard shows what the integrated end of this spectrum looks like. Its voice input is built into the keyboard and, according to its documentation, offers Whisper models alongside lighter streaming models, so you can study how a production IME wires a recognizer into its suggestion strip and committing flow.

20.6 — Android 17 Audio Changes API 37

Android 17 restricts what an app may do with audio when it is not in a valid lifecycle state. Audio playback and volume-change APIs fail silently, without an exception, and audio focus requests return AUDIOFOCUS_REQUEST_FAILED, unless the app is visible or has a foreground service. For apps targeting API 37 the rule is stricter: the foreground service must have while-in-use capabilities, unless the app holds the exact alarm permission and plays USAGE_ALARM streams.

The documented list covers playback, focus, and volume. It does not list microphone capture, so a dictation session that only records is not what this change targets. Audio feedback is: start and stop tones for dictation, key-click sounds (Section 17), and any spoken confirmation. Test all of them on an Android 17 device with targetSdk = 37 (see Section 24) rather than assuming a visible keyboard window is enough.

Android 17 also adds a BYPASS_CONCURRENT_RECORD_AUDIO_RESTRICTION permission for concurrent audio capture during calls and other sensitive audio use cases. Most dictation keyboards will not need it, so do not request it without such a use case, and describe any microphone use accurately in your Data Safety declaration (Section 25).

Section 21

Clipboard Management

Intermediate

Implement a clipboard history panel, rich content pasting (images, URIs), and the ClipboardManager API with Android 13+ privacy restrictions.

21.1 — Clipboard API Basics

import android.content.ClipboardManager
import android.content.ClipData
import android.content.ClipDescription

class KeyboardClipboardManager(private val service: InputMethodService) {
    private val clipboardManager = service.getSystemService(ClipboardManager::class.java)

    fun pasteFromClipboard() {
        val clip = clipboardManager.primaryClip ?: return
        if (clip.itemCount == 0) return

        val item = clip.getItemAt(0)
        val text = item.coerceToText(service)
        if (text.isNotEmpty()) {
            service.currentInputConnection?.commitText(text, 1)
        }
    }

    fun hasClipboardContent(): Boolean {
        return clipboardManager.hasPrimaryClip() &&
            clipboardManager.primaryClipDescription
                ?.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) == true
    }
}

21.2 — Clipboard History

Android 13 (API 33) restricts clipboard access: apps cannot read clipboard content unless they are the current IME or the currently focused app. IMEs are explicitly exempted from this restriction, making keyboards the ideal place for clipboard history.

class ClipboardHistoryStore(private val maxEntries: Int = 20) {
    private val history = ArrayDeque<ClipEntry>(maxEntries)

    data class ClipEntry(
        val text: String,
        val timestamp: Long,
        val isPinned: Boolean = false,
    )

    fun addEntry(text: String) {
        // Don't add duplicate of the most recent entry
        if (history.firstOrNull()?.text == text) return

        history.addFirst(ClipEntry(text = text, timestamp = System.currentTimeMillis()))

        // Remove oldest unpinned entries beyond limit
        while (history.size > maxEntries) {
            val candidate = history.indexOfLast { !it.isPinned }
            if (candidate >= 0) history.removeAt(candidate) else break
        }
    }

    fun getHistory(): List<ClipEntry> = history.toList()

    fun togglePin(index: Int) {
        if (index in history.indices) {
            val entry = history[index]
            history[index] = entry.copy(isPinned = !entry.isPinned)
        }
    }

    fun deleteEntry(index: Int) {
        if (index in history.indices) history.removeAt(index)
    }

    fun clearUnpinned() {
        history.removeAll { !it.isPinned }
    }
}

21.3 — Rich Content (Images & URIs)

API 25+ supports commitContent() for sending images, GIFs, and other media from the keyboard to an app:

import android.view.inputmethod.InputContentInfo

fun commitImage(
    service: InputMethodService,
    contentUri: Uri,
    mimeType: String,
    description: ClipDescription,
) {
    val inputContentInfo = InputContentInfo(
        contentUri,
        description,
        null,    // linkUri — optional web URL
    )

    val ic = service.currentInputConnection ?: return
    val editorInfo = service.currentInputEditorInfo ?: return

    // Check if the target app accepts this MIME type
    val supportedMimes = EditorInfoCompat.getContentMimeTypes(editorInfo)
    val isSupported = supportedMimes.any {
        ClipDescription.compareMimeTypes(mimeType, it)
    }
    if (!isSupported) return

    InputConnectionCompat.commitContent(
        ic, editorInfo, inputContentInfo,
        InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION,
        null,
    )
}
Clipboard Privacy

Never store clipboard entries from password fields. Check EditorInfo.inputType before capturing clipboard content. Also, auto-clear unpinned clipboard history after a configurable timeout (e.g., 60 minutes) to minimize data retention. See privacy considerations in Section 19.

Section 22

Performance Optimization

Expert

Achieve consistent sub-16ms frame times and instantaneous key response. Covers rendering optimization, memory management, startup latency, profiling tools, and Android-specific performance traps for IME developers.

22.1 — IME Startup Latency

The time from tapping a text field to the keyboard being fully visible and interactive is the most critical performance metric. Target: under 200ms cold start, under 100ms warm start.

OptimizationImpactEffort
Pre-inflate keyboard layout in onCreate()50-100ms saved on first showLow
Lazy-load dictionaries (async)100-300ms off cold startMedium
Cache rendered key bitmaps10-30ms per frame savedMedium
Avoid View.inflate() in onCreateInputView()20-50ms savedLow — reuse cached view
Use ViewStub for secondary panels30-80ms deferredLow
Minimize Hilt injection graph15-40ms on DI initMedium

22.2 — Rendering Optimization

// Pre-allocate all Paint objects — NEVER create Paint in onDraw()
class OptimizedKeyboardRenderer {
    // Paint objects created once and reused
    private val keyFacePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.FILL
    }
    private val keyLabelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        textAlign = Paint.Align.CENTER
        typeface = Typeface.DEFAULT_BOLD
    }
    private val pressedOverlayPaint = Paint().apply {
        style = Paint.Style.FILL
    }

    // Pre-allocated RectF — reused per key
    private val keyRect = RectF()

    // Double-buffered: draw to offscreen bitmap, blit to canvas
    private var buffer: Bitmap? = null
    private var bufferCanvas: Canvas? = null
    private var isDirty = true

    fun onSizeChanged(w: Int, h: Int) {
        buffer?.recycle()
        buffer = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
        bufferCanvas = Canvas(buffer!!)
        isDirty = true
    }

    fun drawKeyboard(canvas: Canvas, keys: List<KeyState>) {
        if (isDirty) {
            val bc = bufferCanvas ?: return
            bc.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)

            for (key in keys) {
                drawSingleKey(bc, key)
            }
            isDirty = false
        }
        buffer?.let { canvas.drawBitmap(it, 0f, 0f, null) }
    }

    fun invalidateKey(keyIndex: Int) {
        isDirty = true    // Mark for redraw on next frame
    }

    private fun drawSingleKey(canvas: Canvas, key: KeyState) {
        keyRect.set(
            key.x.toFloat(), key.y.toFloat(),
            (key.x + key.width).toFloat(), (key.y + key.height).toFloat(),
        )

        keyFacePaint.color = key.backgroundColor
        canvas.drawRoundRect(keyRect, key.cornerRadius, key.cornerRadius, keyFacePaint)

        keyLabelPaint.color = key.labelColor
        keyLabelPaint.textSize = key.textSize
        canvas.drawText(
            key.label,
            keyRect.centerX(),
            keyRect.centerY() + key.textSize / 3f,    // Vertical centering
            keyLabelPaint,
        )
    }
}

22.3 — Memory Management

// Pooled key state objects — avoid GC pressure during rapid typing
class KeyStatePool(initialCapacity: Int = 48) {
    private val pool = ArrayDeque<MutableKeyState>(initialCapacity)

    init {
        repeat(initialCapacity) { pool.add(MutableKeyState()) }
    }

    fun acquire(): MutableKeyState {
        return pool.removeFirstOrNull() ?: MutableKeyState()
    }

    fun release(state: MutableKeyState) {
        state.reset()
        pool.addLast(state)
    }
}

class MutableKeyState {
    var x = 0; var y = 0; var width = 0; var height = 0
    var label = ""; var labelColor = 0; var backgroundColor = 0
    var textSize = 0f; var cornerRadius = 0f; var isPressed = false

    fun reset() {
        x = 0; y = 0; width = 0; height = 0
        label = ""; labelColor = 0; backgroundColor = 0
        textSize = 0f; cornerRadius = 0f; isPressed = false
    }
}

22.4 — Profiling Tools

ToolWhat It MeasuresWhen to Use
Android Studio ProfilerCPU, memory, energy, networkGeneral performance analysis
adb shell dumpsys gfxinfo <package>Frame rendering timesDetect janky frames
PerfettoSystem-wide trace (scheduling, binder, GPU)Deep latency analysis
Debug.startMethodTracing()Method-level CPU profilingFind hot methods in draw/touch
Trace.beginSection() / endSection()Custom trace sections in systraceMeasure specific code paths
LeakCanaryMemory leaksDebug builds — detect view/context leaks

22.5 — Android 16: System-Triggered Profiling & ADPF Headroom API 36

Android 16 adds two performance APIs that are particularly valuable for keyboard apps:

System-Triggered Profiling (ProfilingManager)

Instead of manually attaching profilers during development, register your keyboard for system-triggered profiles. The system automatically captures traces on ANR events (API 36) and, from Android 17, on cold starts, out-of-memory errors, and other performance-critical moments (see 22.7):

// Register once in Application.onCreate() or service creation
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun registerSystemProfiling(context: Context) {
    val profilingMgr = context.getSystemService(ProfilingManager::class.java)
    profilingMgr.registerForAllProfilingResults(
        context.mainExecutor,
    ) { result ->
        // System captured a trace for one of your registered triggers
        Timber.i("[PERF] System-triggered profile: tag=${result.tag}, " +
            "path=${result.resultFilePath}")
        // Upload trace file to your crash/perf analytics backend
        uploadTraceToAnalytics(result.resultFilePath, result.tag)
    }
}

// Also use ApplicationStartInfo for cold-start diagnostics
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun logStartupComponent(context: Context) {
    val info = (context as? Application)
        ?.getHistoricalProcessStartReasons(1)
        ?.firstOrNull()
    info?.let {
        Timber.i("[PERF] Last start component: ${it.startComponent}, " +
            "type=${it.startType}")
    }
}

ADPF Thermal Headroom for ML Inference

Keyboards that perform on-device ML (autocorrect models, voice recognition, next-word prediction) can now query CPU and GPU thermal headroom before launching inference. This prevents thermal throttling that causes stuttery typing on sustained use:

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
class ThermalAwareInference(private val context: Context) {
    private val healthMgr = context.getSystemService(SystemHealthManager::class.java)

    enum class InferenceMode { FULL, LIGHTWEIGHT, FALLBACK }

    /** Choose inference mode based on current thermal state.
     *  Headroom values range from 0–100, where 0 = no resources available.
     *  Returns Float.NaN if temporarily unavailable — treat NaN as "unknown, proceed cautiously." */
    fun selectInferenceMode(): InferenceMode {
        val cpuHeadroom = healthMgr.getCpuHeadroom(null)   // null = default params
        val gpuHeadroom = healthMgr.getGpuHeadroom(null)

        if (cpuHeadroom.isNaN() || gpuHeadroom.isNaN()) {
            return InferenceMode.LIGHTWEIGHT  // Unknown state — don't go full throttle
        }

        return when {
            cpuHeadroom > 50f && gpuHeadroom > 40f -> InferenceMode.FULL
            cpuHeadroom > 20f -> InferenceMode.LIGHTWEIGHT
            else -> InferenceMode.FALLBACK  // Use simple dictionary lookup
        }
    }
}

Adaptive Refresh Rate (ARR)

Android 16's hasArrSupport() API lets the system vary display refresh rate per-frame. Keyboards should request high refresh rates (90–120 Hz) during gesture trail animations and key press animations, then drop to the base rate during idle display:

  • getSuggestedFrameRate() — query the optimal rate for your current content type
  • RecyclerView 1.4 has built-in ARR integration — suggestion strips using RecyclerView get smooth scrolling for free
  • Idle keyboards should allow the system to drop to 60 Hz (or lower on LTPO panels) to save battery
Performance Monitoring Strategy

Combine ProfilingManager system-triggered traces with your own Trace.beginSection() custom trace sections. When the system captures a profile (a cold-start profile on Android 17, for example), your custom sections will appear in the Perfetto trace, giving you both system-level and application-level visibility into startup bottlenecks.

22.6 — Android 17: App Memory Limits API 37

Android 17 adds a per-process memory limiter, so an IME that stays resident for days now runs inside a budget that depends on the device's total RAM. The platform enforces it with Linux cgroup v2. memory.high is a soft limit: when a process crosses it, the kernel throttles the process and reclaims its memory. memory.swap.max caps the swap (such as ZRAM) the process can use. An app that stays within its limits is not affected. In the extreme case, an uncontrolled leak that keeps allocating anonymous memory after swap is exhausted, allocation fails and the process is terminated.

Device RAMVisible ProcessNot Visible Process
4 GB2,048 MiB1,024 MiB
6 GB4,096 MiB2,048 MiB
8 GB5,120 MiB3,072 MiB
12 GB8,192 MiB4,096 MiB
16 GB10,240 MiB5,120 MiB

Reference limits from the AOSP memory limiter documentation, as of October 2026. The swap cap in each tier equals the not-visible figure. The limits live in a platform configuration file, so a manufacturer's values can differ.

Which column applies to your IME depends on its process state. The AOSP page maps processes in the TOP, BOUND_TOP, and IMPORTANT_FOREGROUND states to the visible limit and background services and similar states to the not-visible limit, and cached processes are frozen and reclaimed. The platform offers no runtime API to ask for your assigned limit, so measure on real devices and plan against the not-visible figures until you know which tier your IME lands in.

For a keyboard, the practical consequences are:

  • Count everything in the process: Dictionaries, language models (Section 19), recognizer models (Section 20), cached key bitmaps (22.2), and native heaps all count against the same budget.
  • Prefer memory-mapped, read-only data: When a process exceeds memory.high, the kernel evicts clean file-backed memory and swaps out inactive anonymous memory. Weights and dictionaries mapped from files are far easier for it to reclaim than the same bytes copied onto the heap.
  • Release heavy objects when the keyboard hides: The advice from Section 01 to clean up in onFinishInputView() now also protects you from enforcement.
  • Scale caches to the device: A 4 GB phone with a 1,024 MiB not-visible budget cannot hold what a 12 GB phone can.

When the limiter does terminate your process, ApplicationExitInfo reports REASON_OTHER with a description that contains MemoryLimiter:AnonSwap. Read it on the next launch and send it to your crash backend:

// Log memory-limiter terminations on the next launch (Android 17+)
fun logMemoryLimiterKills(context: Context) {
    val am = context.getSystemService(ActivityManager::class.java)
    am.getHistoricalProcessExitReasons(context.packageName, 0, 5)
        .filter {
            it.reason == ApplicationExitInfo.REASON_OTHER &&
                it.description?.contains("MemoryLimiter:AnonSwap") == true
        }
        .forEach {
            Timber.w("[MEM] Killed by memory limiter at ${it.timestamp}, rss=${it.rss} KB")
        }
}

22.7 — Android 17: New ProfilingManager Triggers API 37

Android 17 adds four system triggers to ProfilingManager. Each trigger is registered with addProfilingTriggers(), as in the API 36 example in Section 02, and results arrive only in the global listener from registerForAllProfilingResults() shown in 22.5:

TriggerFires WhenWhat You Get
TRIGGER_TYPE_COLD_STARTAs early as possible during an app cold startA call stack sample and a system trace. Profiling continues until Activity.reportFullyDrawn() is called, or stops after a default of 5 seconds, which is what applies to an IME process with no activity.
TRIGGER_TYPE_OOMThe app throws an OutOfMemoryErrorA Java heap dump. Any custom Thread.UncaughtExceptionHandler must call through to the default handler, or the trigger cannot be used.
TRIGGER_TYPE_KILL_EXCESSIVE_CPU_USAGEThe app is killed for abnormal and excessive CPU usageA call stack sample.
TRIGGER_TYPE_ANOMALYThe system detects anomalous behavior such as excessive binder calls or excessive memory useAn artifact that varies by anomaly, with a tag naming it. For a breach of the OS memory limits, a heap dump, delivered before the system enforces anything.

The anomaly trigger is the natural companion to 22.6: it hands you a heap dump of your own process at the moment the system notices a memory problem. Keep in mind that you can register only one trigger of each type, and that requests are rate limited and not guaranteed to be fulfilled.

// API 37+: ask for a heap dump when the system detects a problem, before it enforces anything
@RequiresApi(37)
fun registerMemoryTriggers(context: Context) {
    val pm = context.getSystemService(ProfilingManager::class.java)
    pm.addProfilingTriggers(
        listOf(
            ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_ANOMALY).build(),
            ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_OOM).build(),
        )
    )
    // Results arrive in the listener from registerForAllProfilingResults() shown in 22.5
}
Section 23

Accessibility

Intermediate

Make keyboards usable by everyone: TalkBack support, Switch Access, content descriptions, touch exploration, high-contrast mode, and WCAG compliance for custom-rendered key surfaces.

23.1 — TalkBack & Explore by Touch

When TalkBack is active, users explore the keyboard by dragging their finger. The system calls onHoverEvent() to determine which key is under the finger, then speaks its description:

import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import androidx.core.view.AccessibilityDelegateCompat
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.customview.widget.ExploreByTouchHelper

class KeyboardAccessibilityDelegate(
    private val hostView: View,
    private val getKeys: () -> List<KeyDefinition>,
) : ExploreByTouchHelper(hostView) {

    override fun getVirtualViewAt(x: Float, y: Float): Int {
        val keys = getKeys()
        for (i in keys.indices) {
            val key = keys[i]
            if (x >= key.x && x < key.x + key.width &&
                y >= key.y && y < key.y + key.height
            ) {
                return i
            }
        }
        return HOST_ID
    }

    override fun getVisibleVirtualViews(virtualViewIds: MutableList<Int>) {
        for (i in getKeys().indices) {
            virtualViewIds.add(i)
        }
    }

    override fun onPopulateNodeForVirtualView(
        virtualViewId: Int,
        node: AccessibilityNodeInfoCompat,
    ) {
        val key = getKeys().getOrNull(virtualViewId) ?: return
        node.contentDescription = key.contentDescription
            ?: key.label.ifEmpty { "Unknown key" }
        node.className = "android.widget.Button"
        node.isClickable = true
        node.setBoundsInParent(Rect(key.x, key.y, key.x + key.width, key.y + key.height))
        node.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK)
    }

    override fun onPerformActionForVirtualView(
        virtualViewId: Int,
        action: Int,
        arguments: Bundle?,
    ): Boolean {
        if (action == AccessibilityNodeInfoCompat.ACTION_CLICK) {
            val key = getKeys().getOrNull(virtualViewId) ?: return false
            // Trigger the same key press as a touch
            hostView.performClick()
            return true
        }
        return false
    }
}

23.2 — Content Descriptions

Key TypeContent DescriptionExample
LetterThe letter itself (uppercase)"A", "Z"
NumberThe number word"One", "Zero"
SymbolFull symbol name"Exclamation mark", "Period"
Space"Space""Space"
Backspace"Delete""Delete"
ShiftState-aware: "Shift off", "Shift on", "Caps lock""Shift on"
EnterAction-aware: "Send", "Search", "Next", "Done""Send"
Language switch"Switch language, current: English""Switch language, current: Spanish"
Voice input"Voice input""Voice input"

23.3 — High Contrast & Large Text

fun isHighContrastEnabled(context: Context): Boolean {
    val am = context.getSystemService(AccessibilityManager::class.java)
    return am.isEnabled && am.isHighTextContrastEnabled
}

fun adjustForAccessibility(
    theme: KeyboardTheme,
    context: Context,
): KeyboardTheme {
    val fontScale = context.resources.configuration.fontScale

    return theme.copy(
        letterTextSize = theme.letterTextSize * fontScale.coerceAtMost(1.3f),
        symbolTextSize = theme.symbolTextSize * fontScale.coerceAtMost(1.3f),
        colors = if (isHighContrastEnabled(context)) {
            theme.colors.copy(
                keyLabel = Color.White,
                keyFace = Color.Black,
                keyFacePressed = Color(0xFF333333),
                keyLabelSecondary = Color(0xFFCCCCCC),
                accentKeyFace = Color(0xFF0050FF),
                accentKeyLabel = Color.White,
            )
        } else {
            theme.colors
        },
    )
}

23.4 — Android 16: Enhanced Accessibility APIs API 36

Android 16 introduces several accessibility APIs that directly improve keyboard usability with assistive technologies:

Multi-Label Support

Keys often carry multiple labels — a primary letter and a secondary number or symbol superscript. Previously, screen readers could only associate one label with an element. Android 16's addLabeledBy() allows associating multiple describing views:

// Android 16+: Multi-label accessibility for composite keys
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
override fun onPopulateNodeForVirtualView(
    virtualViewId: Int,
    node: AccessibilityNodeInfoCompat,
) {
    val key = getKeys().getOrNull(virtualViewId) ?: return
    node.contentDescription = key.label.ifEmpty { "Unknown key" }
    node.className = "android.widget.Button"

    // Primary label + secondary superscript (e.g., "A" with "1" hint)
    key.secondaryLabel?.let { secondary ->
        node.extras.putString(
            "androidx.view.accessibility.supplementalDescription",
            "also enters $secondary"
        )
    }
}

Expandable State for Popup Menus

Long-press popup menus (accent characters, secondary symbols) can now announce their expand/collapse state to screen readers via setExpandedState():

// Announce popup menu state to accessibility services
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
fun updatePopupAccessibility(
    node: AccessibilityNodeInfoCompat,
    isPopupShowing: Boolean,
    key: KeyDefinition,
) {
    node.setExpandedState(isPopupShowing)
    if (isPopupShowing) {
        node.contentDescription = "${key.label}, popup menu open, " +
            "${key.popupCharacters?.length ?: 0} characters available"
    }
}

Outline Text for Maximum Contrast

Android 16 adds a system-level "outline text" setting for users with low vision. When enabled, all rendered text gains a contrasting outline. Keyboard developers should:

  • Test key labels with outline text enabled to ensure readability. Thin/condensed fonts may become illegible with outlines.
  • Avoid competing outlines — if your keyboard already draws text shadows or stroke effects, detect the system setting and disable your custom outlines to prevent visual clutter.
  • Verify popup menus — small popup characters benefit most from outlines but may run out of room with larger glyph footprints.

Additional Android 16 Accessibility Features

FeatureAPIIME Relevance
Indeterminate progressRANGE_TYPE_INDETERMINATEVoice input loading states, dictionary downloads
Tri-state checkboxAccessibilityNodeInfo tri-stateKeyboard settings with partial/mixed selections
Required form fieldssetFieldRequired()When IME detects required fields, announce status
TtsSpan.TYPE_DURATIONNew TtsSpan typeTimer/countdown UI in suggestion strips
LE Audio hearing aidsAudio routing APIsKey click audio and voice input routing
Switch Access

Switch Access users navigate keyboards sequentially (row by row, key by key). Ensure your ExploreByTouchHelper reports virtual views in a logical left-to-right, top-to-bottom order. Test with Settings → Accessibility → Switch Access enabled.

23.5 — Android 17: Text Change Types for Composition API 37

Screen readers handle composing text poorly when every intermediate state sounds like new text. Android 17 closes the loop between the IME and accessibility services in two steps. Your IME sets the candidate-selection flag (Section 09, 9.7). The editor then reports what kind of change it just made by calling AccessibilityEvent.setTextChangeTypes() on TYPE_VIEW_TEXT_CHANGED events, and services read it back with getTextChangeTypes(). Providing the types is optional; if you do not, the value defaults to TEXT_CHANGE_TYPE_UNDEFINED. A single event can combine several types, so the argument is a bit mask.

ConstantUse It When
TEXT_CHANGE_TYPE_IN_COMPOSITIONThe change happened inside an ongoing IME composition. The text is transient and not yet committed, and the event may replace the whole composing text rather than describe an increment.
TEXT_CHANGE_TYPE_COMMITTED_BY_IMEThe change is the IME committing its composing text. The event can look like replacing text with an identical string; the type tells services this is finalization.
TEXT_CHANGE_TYPE_CONVERSION_SUGGESTION_SELECTED_BY_IMEThe user selected a conversion suggestion in the IME. The IME may also send a separate, more detailed event about the selection itself.

A standard TextView does all of this for apps that target API 37. You only write it yourself if you ship a custom editor, for example a "try your keyboard" field built on a custom view, or a test harness (Section 24). In that case, send the event from the code path that receives setComposingText() and commitText():

// API 37+: tell accessibility services how a text change relates to composition
fun notifyTextChanged(isComposing: Boolean, candidateSelected: Boolean) {
    val am = context.getSystemService(AccessibilityManager::class.java)
    if (!am.isEnabled) return

    val event = AccessibilityEvent(AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED)
    onInitializeAccessibilityEvent(event)
    event.text.add(currentText)

    if (Build.VERSION.SDK_INT >= 37) {
        var types = 0
        if (isComposing) {
            types = types or AccessibilityEvent.TEXT_CHANGE_TYPE_IN_COMPOSITION
        }
        if (candidateSelected) {
            types = types or AccessibilityEvent.TEXT_CHANGE_TYPE_CONVERSION_SUGGESTION_SELECTED_BY_IME
        }
        event.setTextChangeTypes(types)
    }
    sendAccessibilityEventUnchecked(event)
}
Test With TalkBack

Compose a word, step through candidates, and commit one with TalkBack on. You should hear the committed text once, not every intermediate candidate as if it were new typing. Check the same flow with a standard EditText and with your custom editor, so you know which of the two is responsible when something sounds wrong.

Section 24

Testing

Intermediate

Test strategies for keyboard applications: unit testing key layout logic, integration testing InputConnection, instrumentation testing with Espresso and UI Automator, and benchmarking frame performance.

24.1 — Testing Pyramid for Keyboards

LayerWhat to TestFrameworkCount
UnitKey layout computation, edit distance, suggestion scoringJUnit 5 + MockK~200-500 tests
IntegrationInputConnection text operations, composing text, IME lifecycleRobolectric or AndroidX Test~50-100 tests
InstrumentationFull keyboard rendering, touch events, theme switchingEspresso + UI Automator~30-60 tests
BenchmarkFrame times, startup latency, dictionary lookup speedMacrobenchmark + Microbenchmark~10-20 tests

24.2 — Unit Testing Key Layout

import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource

class QwertyLayoutTest {
    @Test
    fun `QWERTY row 1 has 10 keys`() {
        val layout = QwertyLayout.build(widthPx = 1080, density = 2.75f)
        assertEquals(10, layout.rows[0].keys.size)
    }

    @Test
    fun `all keys are within keyboard bounds`() {
        val layout = QwertyLayout.build(widthPx = 1080, density = 2.75f)
        for (row in layout.rows) {
            for (key in row.keys) {
                assert(key.x >= 0) { "Key ${key.label} has negative x" }
                assert(key.x + key.width <= 1080) { "Key ${key.label} exceeds width" }
            }
        }
    }

    @ParameterizedTest
    @CsvSource("720,2.0", "1080,2.75", "1440,3.5")
    fun `key sizes scale with density`(widthPx: Int, density: Float) {
        val layout = QwertyLayout.build(widthPx = widthPx, density = density)
        val keyWidthDp = layout.rows[0].keys[0].width / density
        // Standard letter key should be ~32-36dp wide
        assert(keyWidthDp in 28f..40f) {
            "Key width ${keyWidthDp}dp outside expected range at density $density"
        }
    }
}

24.3 — Testing InputConnection

import android.view.inputmethod.BaseInputConnection
import io.mockk.mockk
import io.mockk.verify

class InputConnectionTest {
    private val editable = android.text.SpannableStringBuilder("Hello World")
    private val connection = BaseInputConnection(mockk(relaxed = true), true).apply {
        // Set initial text
    }

    @Test
    fun `commitText inserts at cursor position`() {
        val ic = FakeInputConnection(initialText = "Hello ")
        ic.commitText("World", 1)
        assertEquals("Hello World", ic.getText())
    }

    @Test
    fun `deleteSurroundingText removes characters before cursor`() {
        val ic = FakeInputConnection(
            initialText = "Helloo",
            cursorPosition = 6,
        )
        ic.deleteSurroundingText(1, 0)
        assertEquals("Hello", ic.getText())
    }

    @Test
    fun `composing text is replaced by subsequent commits`() {
        val ic = FakeInputConnection(initialText = "")
        ic.setComposingText("hel", 1)
        ic.setComposingText("hell", 1)
        ic.setComposingText("hello", 1)
        ic.finishComposingText()
        assertEquals("hello", ic.getText())
    }
}

24.4 — Benchmarking Frame Performance

import androidx.benchmark.macro.ExperimentalBaselineProfilesApi
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.CompilationMode
import org.junit.Rule
import org.junit.Test

class KeyboardBenchmark {
    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun startup() = benchmarkRule.measureRepeated(
        packageName = "com.example.keyboard",
        metrics = listOf(
            androidx.benchmark.macro.StartupTimingMetric(),
        ),
        iterations = 10,
        startupMode = StartupMode.COLD,
        compilationMode = CompilationMode.DEFAULT,
    ) {
        pressHome()
        // Open app that will trigger keyboard
    }

    @Test
    fun frameTiming() = benchmarkRule.measureRepeated(
        packageName = "com.example.keyboard",
        metrics = listOf(
            androidx.benchmark.macro.FrameTimingMetric(),
        ),
        iterations = 5,
    ) {
        // Simulate rapid typing
    }
}
CI Integration

Run unit tests on every PR. Run instrumentation tests nightly on a Firebase Test Lab device farm covering at least 5 screen densities and 3 API levels (latest, latest-2, minSdk). Run benchmarks weekly and track regression trends in a dashboard.

24.5 — Testing Android 17 and 16 KB Page-Size Behavior API 37

Android 17 adds behavior that a unit test cannot catch: limits the system enforces, windows the system resizes, and accessibility events that only a screen reader exposes. Add a short device pass to your release routine. Build a variant with targetSdk = 37 and run it on an Android 17 device or emulator image next to your regular targetSdk = 36 build, so you can tell a platform change from a bug of your own.

Memory Limiter

The am memory-limiter command lets you inspect and rehearse the limits from Section 22 (22.6). The AOSP documentation describes the accepted units for manual; check it before scripting the call.

# Report the memory limiter's monitoring state, current limits, and event counts
adb shell am memory-limiter status

# Find your keyboard's process, then apply a custom limit to rehearse a small device
adb shell pidof com.example.keyboard
adb shell am memory-limiter manual <PID> <limit>

# Temporarily exclude a UID from limiting while you profile
adb shell am memory-limiter ignore <UID>

After you force a small limit, type heavily, open the emoji and clipboard panels, and switch languages. Then read ApplicationExitInfo for a MemoryLimiter:AnonSwap description (22.6) to confirm whether the process was terminated.

16 KB Page Size

Test on a 16 KB environment before you ship native code (see Section 25 for the requirement). In Android Studio, install one of the experimental 16 KB page-size system images from the SDK Manager, or use the Boot with 16KB page size developer option on the supported Pixel devices that offer it. Then verify the environment and your build:

# After booting a 16 KB device or emulator, confirm the page size (expect 16384)
adb shell getconf PAGE_SIZE

# Verify that the shared libraries in your APK are 16 KB aligned
zipalign -c -P 16 -v 4 app-release.apk

# Verify that an app bundle requests 16 KB zip alignment (look for PAGE_ALIGNMENT_16K)
bundletool dump config --bundle=app-release.aab | grep alignment

Cold-start the keyboard on that image, then exercise every native path: dictionary loading, gesture typing, and any recognizer or language-model runtime. A library that is not aligned fails at load time, so a green cold start proves less than a full pass through each feature.

Behaviors to Exercise With targetSdk 37

BehaviorHow to Exercise ItWhat to Check
Keyboard visibility after rotationRotate the device in an app that does not handle configuration changes, and in your own activitiesYour own screens request the keyboard again (Section 05, 5.7)
Large-screen enforcementUse a tablet or foldable (600 dp and wider), resize the window, and rotateSettings and onboarding adapt; keyboard dimensions are recomputed (Section 15, 15.5)
Composition accessibilityRun TalkBack with a standard and a custom editorCandidate selection and commit are announced once (Section 23, 23.5)
Background audioTrigger key clicks and dictation tones with the keyboard hiddenNo silent failures; audio focus requests return a granted result (Section 20, 20.6)
Memory limitsApply a manual limit and stress the keyboardNo termination within the budget you set (22.6)
System profilingRegister the Android 17 triggers and reproduce a slow startResults arrive in your listener (22.7)
Section 25

Deployment & Publishing

Intermediate

Prepare your keyboard for production: Play Store requirements, IME enablement flow, multi-keyboard switching, update strategy, and review guidelines specific to input method apps.

25.1 — IME Enablement Flow

Unlike regular apps, keyboards require two steps before they can be used: (1) the user must enable the IME in System Settings, and (2) the user must select it as the active keyboard. Guide users through both steps:

import android.provider.Settings
import android.view.inputmethod.InputMethodManager

fun isImeEnabled(context: Context, imePackage: String): Boolean {
    val enabledImes = Settings.Secure.getString(
        context.contentResolver,
        Settings.Secure.ENABLED_INPUT_METHODS,
    ) ?: return false
    return enabledImes.contains(imePackage)
}

fun openImeSettings(context: Context) {
    context.startActivity(
        Intent(Settings.ACTION_INPUT_METHOD_SETTINGS).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        },
    )
}

fun showImePicker(context: Context) {
    val imm = context.getSystemService(InputMethodManager::class.java)
    imm.showInputMethodPicker()
}

25.2 — Keyboard Switching

Declare supportsSwitchingToNextInputMethod in your method.xml so the system shows the globe/language-switch key:

<input-method xmlns:android="http://schemas.android.com/apk/res/android"
    android:settingsActivity=".ui.SettingsActivity"
    android:supportsSwitchingToNextInputMethod="true">

    <subtype
        android:imeSubtypeMode="keyboard"
        android:languageTag="en-US"
        android:isAsciiCapable="true"
        android:label="@string/subtype_en_us" />
</input-method>

In your service, implement the globe key to cycle between installed IMEs:

fun handleGlobeKeyPress() {
    // switchToNextInputMethod(boolean) is an InputMethodService instance method
    // The boolean controls whether to include IMEs that don't support switching
    switchToNextInputMethod(false)
}

25.3 — Play Store Requirements

RequirementDetailsStatus
Privacy PolicyRequired for all IMEs — must disclose what keystrokes/data are collectedMandatory
Target API LevelMust target API 36 (Android 16) for new apps and updates as of Aug 31, 2026 (an extension to Nov 1, 2026 could be requested); existing apps must target API 35+ to remain visible to new users. API 37 is not required yet as of October 2026Mandatory
16 KB Page SizeApps targeting Android 15 (API 35) and higher must support 16 KB memory page sizes on 64-bit devices. According to Google's documentation, updates that do not comply cannot be released from Feb 1, 2027; confirm the date in your Play Console banner. In practice this matters to keyboards that ship native libraries (see 25.5)Mandatory
FULL_NETWORK_ACCESSIf your IME uses network (cloud speech, sync), disclose in listingIf applicable
Data Safety SectionDeclare all data collection (keystrokes, voice audio, analytics)Mandatory
Deceptive BehaviorIME must not log keystrokes for advertising or send to undisclosed serversPolicy violation
Accessibility ServiceDo not request AccessibilityService permission unless you are an accessibility toolPolicy violation risk

25.4 — Release Checklist

Pre-Release Checklist
  1. Test on at least 3 screen densities (hdpi, xxhdpi, xxxhdpi) and 3 API levels
  2. Verify TalkBack navigation works for all keys (see Section 23)
  3. Enable R8/ProGuard and test the minified build — keyboard Views are often broken by shrinking
  4. Ensure onCreateInputView() handles null keyboard gracefully after process death
  5. Test with split-screen and floating window modes
  6. Verify the IME shows correctly in both portrait and landscape (Section 15)
  7. Run adb shell dumpsys input_method to verify subtypes are registered
  8. If you ship native libraries, run zipalign -c -P 16 on the release APK and test on a 16 KB device or emulator (see Section 24, 24.5)
  9. Before moving targetSdk to 37, run the Android 17 behavior checks in Section 24 (24.5)
  10. Generate a Baseline Profile for faster cold start

25.5 — 16 KB Page Size & Moving to API 37

Many keyboards ship native code: a dictionary engine, a gesture-typing library, or an ML runtime for the features in Section 19 and Section 20. Starting with Android 15, devices can use 16 KB memory pages, and shared libraries must be aligned to 16 KB to load on them. Google Play requires all apps that target Android 15 (API 35) and higher to support 16 KB page sizes on 64-bit devices. According to Google's page-size documentation, starting February 1, 2027, updates that do not support it cannot be released. Secondary sources have quoted other dates, so confirm yours in the Play Console compatibility warning before you plan around any of them.

  1. Upgrade the build tools: Use Android Gradle Plugin 8.5.1 or higher with uncompressed shared libraries, and NDK r28 or higher, which compiles 16 KB-aligned by default. With NDK r27 or lower, pass -Wl,-z,max-page-size=16384 and -Wl,-z,common-page-size=16384 to the linker.
  2. Rebuild or replace prebuilt libraries: Every .so you bundle counts, including dictionary engines and ML runtimes. Update third-party SDKs to versions built for 16 KB.
  3. Verify the package: Run zipalign -c -P 16 against the APK (the command appears in Section 24). Its last line reads "Verification successful" when every library is aligned.
  4. Fix code that assumes 4 KB pages: Look for hard-coded page-size constants and custom memory allocators, then retest on a 16 KB device.

Moving targetSdk to 37 is a separate decision. Raising it opts your keyboard in to the Android 17 behavior changes described in Section 02, including large-screen enforcement that you can no longer opt out of. A safe sequence is to compile against API 37 first, run the device pass from Section 24, and then ship targetSdk = 37 in a staged rollout:

android {
    compileSdk = 37      // Android 17 — build against the newest platform
    defaultConfig {
        targetSdk = 36   // Raise to 37 once the checks in Section 24 pass
    }
}
Section 26

Complete Code Examples

Beginner Intermediate

End-to-end runnable keyboard implementations. Each example is self-contained with all required files listed. Copy into an Android Studio project to build and test immediately.

26.1 — Minimal Working Keyboard

The absolute minimum code to display a functional keyboard. Three files total:

File 1: AndroidManifest.xml (IME registration)

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <service
            android:name=".MinimalKeyboardService"
            android:permission="android.permission.BIND_INPUT_METHOD"
            android:exported="true">
            <intent-filter>
                <action android:name="android.view.InputMethod" />
            </intent-filter>
            <meta-data
                android:name="android.view.im"
                android:resource="@xml/method" />
        </service>
    </application>
</manifest>

File 2: res/xml/method.xml

<input-method xmlns:android="http://schemas.android.com/apk/res/android"
    android:supportsSwitchingToNextInputMethod="true">
    <subtype
        android:imeSubtypeMode="keyboard"
        android:languageTag="en-US"
        android:isAsciiCapable="true"
        android:label="@string/subtype_en" />
</input-method>

File 3: MinimalKeyboardService.kt

import android.inputmethodservice.InputMethodService
import android.view.View
import android.widget.Button
import android.widget.LinearLayout

class MinimalKeyboardService : InputMethodService() {

    override fun onCreateInputView(): View {
        // Build a simple 3×1 keyboard: A, B, ⌫
        val row = LinearLayout(this).apply {
            orientation = LinearLayout.HORIZONTAL
            layoutParams = LinearLayout.LayoutParams(
                LinearLayout.LayoutParams.MATCH_PARENT,
                LinearLayout.LayoutParams.WRAP_CONTENT,
            )
        }

        val keys = listOf("A" to 65, "B" to 66)
        for ((label, code) in keys) {
            row.addView(
                Button(this).apply {
                    text = label
                    layoutParams = LinearLayout.LayoutParams(0, 160, 1f)
                    setOnClickListener {
                        currentInputConnection?.commitText(label, 1)
                    }
                },
            )
        }
        // Delete key
        row.addView(
            Button(this).apply {
                text = "⌫"
                layoutParams = LinearLayout.LayoutParams(0, 160, 1f)
                setOnClickListener {
                    currentInputConnection?.deleteSurroundingText(1, 0)
                }
            },
        )
        return row
    }
}
Why This Works

Every keyboard needs exactly three things: (1) a Service with BIND_INPUT_METHOD permission, (2) a method.xml metadata file declaring subtypes, and (3) an onCreateInputView() that returns the keyboard View. Everything else — themes, gestures, haptics, autocorrect — builds on top of this foundation.

26.2 — Multi-Language Keyboard with Subtype Switching

import android.inputmethodservice.InputMethodService
import android.view.inputmethod.InputMethodSubtype

class MultiLanguageKeyboardService : InputMethodService() {

    private var currentLayout: KeyboardLayoutDefinition = QwertyLayout

    override fun onCurrentInputMethodSubtypeChanged(newSubtype: InputMethodSubtype) {
        // Called when user switches subtypes (language)
        currentLayout = when (newSubtype.languageTag) {
            "fr-FR" -> AzertyLayout
            "de-DE" -> QwertzLayout
            else -> QwertyLayout
        }
        setInputView(onCreateInputView())    // Rebuild with new layout
    }

    override fun onCreateInputView(): View {
        return buildKeyboardView(currentLayout)
    }

    private fun buildKeyboardView(layout: KeyboardLayoutDefinition): View {
        // Build rows from layout definition
        // (See Section 06 for full XML-based approach)
        val container = LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
        }
        for (row in layout.rows) {
            val rowView = LinearLayout(this).apply {
                orientation = LinearLayout.HORIZONTAL
            }
            for (key in row) {
                rowView.addView(createKeyButton(key))
            }
            container.addView(rowView)
        }
        return container
    }

    private fun createKeyButton(key: KeyDef): Button {
        return Button(this).apply {
            text = key.label
            setOnClickListener {
                currentInputConnection?.commitText(key.output, 1)
            }
        }
    }
}

// Layout definitions (simplified)
interface KeyboardLayoutDefinition { val rows: List<List<KeyDef>> }
data class KeyDef(val label: String, val output: String)

object QwertyLayout : KeyboardLayoutDefinition {
    override val rows = listOf(
        "QWERTYUIOP".map { KeyDef(it.toString(), it.toString()) },
        "ASDFGHJKL".map { KeyDef(it.toString(), it.toString()) },
        "ZXCVBNM".map { KeyDef(it.toString(), it.toString()) },
    )
}
Section 27

Resources & Further Reading

Beginner

Curated collection of official documentation, open-source keyboard projects, dictionaries, design references, and community resources for Android keyboard developers.

27.1 — Official Documentation

ResourceDescription
Creating an Input MethodOfficial guide — start here for the canonical IME tutorial
InputMethodService ReferenceComplete API reference with all 50+ callbacks documented
InputConnection ReferenceThe text editing interface — every method your keyboard uses to modify text
EditorInfo ReferenceDescribes the text field: input type, IME options, initial text selection
Input Method SubtypesMulti-language support: declaring and switching between keyboard layouts
Image Keyboard SupportCommitting images, GIFs, and stickers from your keyboard
Stylus HandwritingAPI 33+ stylus-to-text integration for handwriting recognition
Android 15 (API 35) FeaturesPlatform additions: edge-to-edge enforcement, predictive back, 16 KB pages
Android 16 (API 36) FeaturesDual-release cadence, richer haptics, enhanced accessibility, ADPF headroom, ARR
Android 17 (API 37) FeaturesIME accessibility for composition, app memory limits, background audio hardening, new ProfilingManager triggers
Android 17 Behavior Changes (All Apps)IME visibility after configuration changes, memory limiter, background audio hardening
Android 17 Behavior Changes (Targeting 17)TextAttribute and AccessibilityEvent composition APIs, large-screen enforcement
AOSP Memory LimiterRAM-tier limits, visible versus not-visible processes, and the am memory-limiter commands
Support 16 KB Page SizesBuild requirements (AGP, NDK), zipalign verification, and emulator images for testing
Google Play Target API Level RequirementsCurrent targetSdk deadlines and extension dates for new apps and updates
Compose Material3 Release NotesStable and alpha versions, and which Material 3 Expressive APIs have graduated from experimental
ML Kit GenAI Speech RecognitionAlpha on-device speech recognition with Basic and Advanced (Gemini Nano) modes
ML Kit GenAI ProofreadingBeta on-device grammar and spelling for short text, with a keyboard input type
ML Kit GenAI RewritingBeta on-device rewriting of short text in different styles and tones
ProfilingManager ReferenceSystem-triggered profiling (API 36), plus cold-start, OOM, CPU, and anomaly triggers added in API 37
Android Dynamic Performance Framework (ADPF)CPU/GPU headroom queries, thermal status, and adaptive frame-rate guidance
SystemHealthManager ReferenceAPI 36 getCpuHeadroom() and getGpuHeadroom() thermal-aware tuning
Compose Text User InteractionsSelectionContainer, DisableSelection, LinkAnnotation, and custom link handlers
Material Design 3Design guidelines, color system, motion, and component specs
Material You Dynamic ColorDynamic color system and tone-based color roles
Haptic Feedback GuidePlatform haptic primitives, custom waveforms, and UX best practices
Accessibility Developer GuideAccessibilityNodeInfo, TalkBack integration, testing, and best practices

27.2 — Open-Source Keyboards

Study these production keyboards for reference implementations:

ProjectLanguageNotable Features
HeliBoardKotlinCustomizable, privacy-conscious keyboard in the OpenBoard / AOSP lineage; version 4.1 (August 2026) is the latest stable release as of October 2026
FUTO KeyboardC++, Java, KotlinAOSP-based, fully offline keyboard with transformer predictions through llama.cpp and integrated voice input. The source is available under the FUTO Source First License rather than an open-source license, so read its terms before reusing code. Documentation · FUTO Voice Input
FlorisBoardKotlinModern architecture, Jetpack Compose UI, extension system
AnySoftKeyboardJava200+ language packs, extensive subtype system

27.3 — Dictionary & Language Resources

ResourceFormatUse Case
AOSP Dictionaries (HeliBoard)AOSP binary dictPre-built frequency dictionaries for 50+ languages
Lexique (French)TSV / SQLComprehensive French lexicon with frequency data
OpenSubtitles Word FreqCSVWord frequency lists from subtitle corpora (100+ languages)
FrequencyWordsTextWord frequency lists derived from OpenSubtitles
Unicode CLDR Keyboard ChartsWeb / XMLStandard keyboard layouts for every locale

27.4 — Design & UX References

ResourceCategory
Compose Keyboard InputFramework integration — physical key events
Compose Text Selection & LinksSelectionContainer, LinkAnnotation.Url, TextLinkStyles, custom URI handlers
Touch Target Size (NN/g)Usability research — optimal touch target dimensions
WCAG 2.2 Understanding DocsAccessibility — contrast ratios, target sizes, motion

27.5 — Internal Cross-References

This guide is part of a documentation set. Related volumes:

GuideFocus
Android Keyboard 3D & Personalization Guide 2026 — Extension Volume3D rendering, shaders, Canvas/OpenGL effects, AGSL, font systems, sound packs, AI-driven personalization
Mobile STT Engineering Guide 2026On-device speech-to-text: audio capture, VAD, model inference, post-processing, evaluation harness
Debugging Field ManualCross-platform debugging across Android, native C/C++, and Python tooling
Staying Current

Android keyboard APIs evolve with each platform release. Starting with Android 16, Google follows a dual-release cadence — a major SDK bump in Q2 and a minor (maintenance + feature) release in Q4 — and Android 17 (API 37) continues it. Bookmark the Android version overview page and check the Input & IME section of each release's behavior changes document. Key areas that change frequently: InputConnection methods, EditorInfo fields, privacy restrictions on clipboard and text access, haptic primitives, accessibility node info APIs, and stylus/handwriting APIs. Use Build.VERSION.SDK_INT_FULL (API 36+) to detect minor-release features at runtime.

Citation

How to Cite This Guide

If you reference this guide in academic work, documentation, or project READMEs, please use one of the formats below. Both are consistent with the output generated by GitHub's "Cite this repository" button and the cffconvert tool from the repository's CITATION.cff file.

APA (7th Edition)

Made in Jurgistan. (2026). Android Keyboard Design Guide (Version 2026.2.0) [Computer software]. https://Made-in-Jurgistan.github.io/android-keyboard-design-guide/

BibTeX

@software{Made_in_Jurgistan_Android_Keyboard_2026,
  author = {Made in Jurgistan},
  month = {1},
  title = {{Android Keyboard Design Guide}},
  url = {https://Made-in-Jurgistan.github.io/android-keyboard-design-guide/},
  version = {2026.2.0},
  year = {2026}
}
GitHub "Cite this repository" button

A CITATION.cff file at the repository root also enables GitHub's built-in "Cite this repository" button (right sidebar on the repo page), which auto-generates APA and BibTeX citations from the same metadata. This button appears on github.com only — it does not render on the published GitHub Pages site, which is why the citation blocks above are included here.