← Back to projects

work · active

How I Took a Classroom AI Assistant to 12 Languages and 4 Panel Brands

Leading an AI assistant for classroom panels sold outside India: telling devices apart on firmware we don't control, Arabic layouts, faster pens through a vendor's SDK, and prompt changes without an app update.

Android · System overlay · InternationalisationKotlin · Jetpack Compose · AccessibilityService · Product flavours · Vendor SDKs

Role
Lead engineer and product owner
Duration
Jan – Oct 2026
Type
work
Status
active

Key results

  • 0Vendor classes in our own panels' apps, checked in the built APKs
  • 12Languages shipped, including Arabic and Persian
  • 4Panel brands supported from one codebase

Overview

Edu AI is an AI assistant that lives on top of whatever a teacher is doing on a classroom panel. It’s a floating button that opens a chat, lets the teacher select anything on screen and ask about it, annotate over other apps, and work alongside a YouTube video. It ships preinstalled as a system app on Teachmint’s panels for markets outside India.

I led the Android app from January to October 2026, writing most of its code, and owned the product dashboard for it.

The problem

A classroom panel abroad is a different device from one in India:

  • Four firmware flavours. Two of our own panel models, plus panels built on OEM platforms from two partner brands. Each has different system properties, vendor SDKs, signing keys and permission rules.
  • Twelve languages. English plus Arabic, Bengali, Persian, Hindi, Indonesian, Kannada, Marathi, Nepali, Sinhala, Thai and Vietnamese. Two of those are right-to-left.
  • No Play Store. Updates, identity and analytics all have to work without Google’s infrastructure to lean on.
  • A system overlay. The app draws on top of other apps, so it lives by window-manager and accessibility rules that ordinary apps never meet.

My approach

The rule I held to was that flavour differences live behind interfaces, and vendor code only ships where it’s needed.

main source set:   OemSerialProvider    OemNativeHelperProvider   …
                         │                       │
       ┌─────────────────┼───────────────────────┼─────────────────┐
  Our own panels      Partner A                Partner B
  no-op default       vendor SDK serial,       vendor service serial,
                      DRM pen path             vendor pen acceleration

Each OEM gets a per-flavour object behind an interface, and its vendor SDK is scoped to that flavour’s dependencies. Our own panels’ APKs ship zero vendor classes, which I checked in the built APKs. Adding a flavour means implementing the interfaces, not adding if (vendor) branches.

Language and prompts get the same separation. The UI’s strings were externalised into 300 translatable keys, with a lint check and a guard script that flag new hardcoded text. The prompts the AI uses live on the server: the app sends what the teacher did and the context, and the server composes the prompt. When the backend moved to a new agent harness in September, every chat surface moved with it, and the app shipped no prompt text.

Important decisions

Identity has to be a ladder, not a property

Our own panels expose their serial number as a system property. Partner panels don’t. There, the serial resolved to one fixed placeholder string, and that one string broke everything keyed on identity: API headers, token refresh, analytics authentication, OTA requests and the updater.

The fix is a lookup ladder. Try the system property, then the OEM’s own API, then fall back to the sentinel. Two rules made it reliable:

  • Never memoise a bad answer. An invalid serial isn’t cached, so a later launch heals itself.
  • Retry around the vendor’s startup. On Partner B the serial comes from a local service that races the app at boot. The app waits for its connect callback and retries before giving up.

A per-device DeviceKind (own panel or a partner’s) then stopped OEM panels from reporting themselves as one of our models.

Measure layout, don’t infer it

Teachers can pick the app’s language independently of the system, and other apps can do the same. So the overlay never assumes which way another app is laid out. When Edu AI docks next to a YouTube video, it measures, through accessibility, which half of the screen the player occupies, instead of guessing from the locale. An Arabic YouTube mirrors its watch page, and a dock that assumed “player on the left” covered the video.

Ship the boring parts per flavour

Distribution was per flavour from the start: a public download location and a version manifest for each, plus our own OTA SDK. In October, Partner B joined the production distribution pipeline alongside the others. On Partner B, annotating over the panel’s own whiteboard is refused outright, because both would draw to the same single vendor ink plane. The refusals are counted separately in analytics, so they don’t pollute the annotation funnel.

What went wrong: pen acceleration on Partner B

Fast inking on Partner B panels goes through the vendor’s writing SDK, which draws strokes on a hardware plane served by a system process. It worked on some boots and not others, with an identical APK. Three fixes shipped, and the wrong turns along the way taught as much as the fixes.

  1. A context without a display. The SDK calls context.getDisplay() during init. From a system overlay we have no Activity, and an application context throws on API 30+. The SDK reported the error as a return code and fell back to software silently. The fix was a display context created from the app context.
  2. R8 renamed a class that crossed a process boundary. In release builds the writing server logged Class not found when unmarshalling: a.b.c.vf3. The SDK sends a Parcelable with writeParcelable, which serialises the class name. R8 had renamed ShareItem to a.b.c.vf3, and the server’s process has no such class. Debug builds aren’t minified, which is why they worked. The fix was keep rules for the vendor packages.
  3. A destroy/init cycle the SDK can’t survive. The accelerated plane is display-sized, but the app rebuilt it on every view re-layout: docking, insets, split screen. The helper also deinitialised unconditionally when initialised twice. One re-layout became destroy() plus init(), after which the SDK’s own canvas was null forever, while it still reported itself renderable. Init is now idempotent per display. Because the ROM starts the app seconds into boot, before the writing server is up, showing the drawing layer also retries init when the helper isn’t ready.

The wrong turns are the part I’d tell another engineer about:

  • A status read backwards. For days, the log line rank=5 was treated as the failure. rank was the enum’s ordinal, and 5 is RENDERABLE: healthy.
  • Two fixes that weren’t. A borrowed authentication token and an SDK version upgrade each looked like the fix, in tests that later proved unreliable. An injected input swipe isn’t a real stylus, and only hand testing on the panel counted.
  • The single-session server. The vendor’s writing server serves one client at a time, and the panel’s own whiteboard app usually holds it. That’s a real limit, and it explains why some tests passed only with the whiteboard stopped.

Because the bug was a race, A/B-ing builds kept giving contradictory answers. The reliable signal was on the server side: does the writing server’s render log line up one-to-one with the app’s render calls?

Debugging and investigation

The right-to-left bugs were the cleanest. In Arabic, the floating Edu AI icon sat on the wrong side of the text it belonged to. The overlay positions itself in physical pixels, but it inherited the locale’s layout direction, so Modifier.offset mirrored x. The icon’s measured position on the panel matched width − x − size exactly: 1400 − 1037 − 48 = 315. The full story, including the attempt that pushed the icon off-screen, is in Fixing 3 Arabic Layout Bugs in an Android App with One Rule.

Languages came with their own honesty problem. The eleven translations were drafted with AI, checked key-for-key against English (300 of 300 keys, no missing or extra ones), and labelled as AI-drafted. One known issue was recorded at commit time: three Thai strings were wrong. Native review is still the gap.

Result

Edu AI ships on all four flavours from one codebase, in twelve languages, with OEM code isolated to the flavours that need it. Device identity heals itself on OEM panels. Pen acceleration on Partner B survives re-layouts, release builds and the boot race, within the vendor server’s one-client limit. Prompt changes reach every panel without an app release.

What’s still open: native-speaker review of the translations, and a way for the app to claim the vendor’s single writing session while the panel’s whiteboard holds it.

What I learned

  1. Put flavour differences behind interfaces from day one. Our own APKs shipping zero vendor classes came from structure, not discipline.
  2. Never cache an identity you know is wrong. A sentinel that persists becomes a permanent outage.
  3. Read vendor status codes against their definition. rank=5 meant the opposite of what we assumed for days.
  4. Anything that crosses a process boundary by class name needs a keep rule. Debug builds will never show you this bug.
  5. Overlays live in pixels. Layout direction is a property of content, not of windows.