A myopia awareness and visual acuity self-screening app for Android. Native Kotlin and Jetpack Compose.
Landolt C acuity test with real screen calibration, local test history with a progression chart, eye-drop reminders on exact alarms, and a short myopia reference section.
Everything stays on the device. The app makes no network calls, has no analytics, and declares no internet permission.
This is a screening aid, not a medical device, and not a diagnosis. It cannot detect glaucoma, cataract, retinal disease or most other eye conditions.
This app is an independent reimplementation. It was written after analysing a Flutter APK called "Oculus 2.0", a student project by a group crediting itself as Team Oculus, which is where the name, the feature set and the idea of a swipe-driven Landolt C test come from.
None of that project's code is reused here — it was a compiled Flutter app, and
this is Kotlin written from scratch. Its article text and images are not
reproduced either; the copy in InfoContent.kt is newly written for this
repository.
The measurement approach differs in one substantive way. The original sized its optotypes in raw logical pixels with no screen calibration, which draws the 6/6 line roughly 2.5x too large at the stated 40 cm working distance. See Calibration below.
cd ~/oculus-android
./gradlew test # 34 unit tests, no device needed
./gradlew check # tests + lint
./gradlew assembleRelease # 1.7 MB APKlocal.properties already points at the SDK. Everything below is installed and
verified working on this machine.
| JDK | 26.0.2.1 (system) |
| Gradle | 9.7.1 |
| AGP | 9.4.1 |
| Kotlin | 2.4.20 |
| KSP | 2.3.12 |
| Compose BOM | 2026.09.00 (Compose 1.12.1, Material3 1.4.0) |
| Room | 2.8.5 |
| compileSdk / targetSdk | 37 |
| minSdk | 26 |
| Android SDK | ~/Android/Sdk |
Release builds are wired to the debug signing config. Replace that in
app/build.gradle.kts before publishing anything.
Yes. Every build in this repo was run on JDK 26.0.2.1, and both halves of the question check out:
Running the toolchain on JDK 26 — fully supported. Gradle's compatibility matrix lists JDK 26 as supported for running Gradle from 9.4.0 onward, and for toolchains from 9.4.0. AGP 9.4.1, Kotlin 2.4.20 and KSP 2.3.12 all run on it without a warning.
Targeting Java 26 bytecode — possible, but don't. Setting
jvmTarget = JVM_26 does work: Kotlin emits class file major version 70 and D8
dexes it into a working APK (verified with --no-build-cache --rerun-tasks).
But it buys nothing. Android's available APIs come from minSdk, not from the
JDK you compiled with, and D8 merely accepting a class file version is not the
same as Google supporting it. This project stays on JVM_17, which is the
documented Android target.
So: use JDK 26 to build, keep jvmTarget at 17.
AGP 9.0 introduced built-in Kotlin support, and it now fails the build if
org.jetbrains.kotlin.android is also applied. That plugin is gone from this
project; org.jetbrains.kotlin.plugin.compose and KSP are still applied
normally. kotlinOptions {} is gone too — JVM target is set through
kotlin { compilerOptions { } }.
Ten levels, 6/60 down to 6/6. Five Landolt C optotypes per level, three correct to descend. The finest line passed is the recorded acuity.
A four-way forced choice means guessing alone passes a line about 10% of the time. Raising the threshold makes the test longer and more tiring without much accuracy gain; lowering it lets guessing through.
Right eye first, then a handover card, then the left. Per-level error counts are kept alongside the score.
A screen-based acuity test only means something if the optotype is drawn at a known physical size. A true 6/6 Landolt C subtends 5 arcmin — at 40 cm that is 0.582 mm.
Android reports DisplayMetrics.xdpi, but on many devices that is a rounded
density bucket rather than a measurement, and on some it is simply wrong. Sizing
the chart from it — or worse, treating a dp as a fixed physical length — draws
the 6/6 line about 2.5x too large: a 9 dp ring is 1.43 mm, so the line
labelled 6/6 is really closer to 6/15.
That error runs the wrong way for a screening tool. It reports vision as better
than it is, so the people who most need an examination are the ones least likely
to be told to get one. AcuityTest.kt pins the arithmetic down.
So the app measures the screen instead. The user matches an on-screen outline to any bank or ID card (ISO/IEC 7810 ID-1, 85.60 mm wide), which gives dp per millimetre; viewing distance is a slider, not an assumption. Both values are stored with every result, and the history chart says so plainly when a series spans a change in either, rather than drawing a trend through readings that are not comparable.
Calibration is also deliberately excluded from cloud backup: it describes the old screen, and restoring it onto a new device would silently produce wrong readings.
app/src/main/kotlin/com/teamoculus/oculus/
OculusApplication.kt manual DI container, channel setup, alarm re-arm
MainActivity.kt
core/
Acuity.kt Snellen/logMAR notation, optotype geometry, calibration
Staircase.kt the scoring engine — no Android imports, directly testable
data/
Models.kt EyeScore, EyeTestResult, Reminder
Entities.kt Room entities + explicit mappers
Daos.kt, OculusDatabase.kt
SettingsStore.kt DataStore, holds the calibration
Repository.kt the one seam between UI and persistence/scheduling
notifications/
ReminderScheduler.kt AlarmManager, exact-alarm capability, next-occurrence maths
ReminderReceiver.kt posts the notification, re-arms the next firing
BootReceiver.kt re-arms everything after a reboot
ui/
theme/, components/ palette, LandoltC, TrendChart, MenuCard
navigation/ routes and the NavHost
screens/ home, instruction, test, result, history, reminder, info
app/src/test/kotlin/... 34 unit tests, plain JVM, no device
The scoring engine has no Android dependency. AcuityStaircase is plain
Kotlin, which is what lets StaircaseTest drive whole test sittings to
completion in milliseconds without an emulator or Robolectric.
Optotypes are drawn, not shipped. LandoltC is a Compose Canvas using the
standard proportions (outer diameter 5 units, stroke 1, gap 1). Drawing it lets
the ring be sized to the millimetre and keeps the four orientations identical
apart from rotation. The launcher and notification icons are the same shape as
vector drawables, generated from the same geometry.
A repeating reminder is a chain of one-shot exact alarms. Every repeating
AlarmManager variant is inexact, and a medication reminder has to land on the
minute, so each firing schedules the next. nextOccurrenceMillis is pure and
has its own tests — off-by-one-day errors here are invisible until someone
misses a dose.
Permission failure is visible. A reminder that silently never fires is worse than no reminder, because the user stops watching for it themselves. Missing notification permission and missing exact-alarm permission each get their own banner, and the scheduler degrades to an inexact alarm rather than throwing.
The test screen resists interference. Plain white regardless of theme, Material You off by default (a wallpaper-derived palette could tint the optotype), and leaving mid-test asks first.
Room never destructively migrates. The user's history is the point of the
app; wiping it on a schema change would be the worst possible default. Add a
real Migration when the schema moves.
The reference articles are written for this project and kept deliberately light
on specific figures. Anything making a clinical claim should be checked against a
primary source and cited in InfoArticle.sources before you publish — the
International Myopia Institute white papers and the WHO World Report on Vision
are the usual starting points.