Skip to content

Repository files navigation

Oculus

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.

Origin

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.

Build

cd ~/oculus-android
./gradlew test              # 34 unit tests, no device needed
./gradlew check             # tests + lint
./gradlew assembleRelease   # 1.7 MB APK

local.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.

Can you build this on JDK 26?

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 gotcha

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 { } }.

How the test works

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.

Calibration, and why it is not optional

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.

Layout

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

Design notes

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.

Content

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.

About

Visual acuity self-screening for Android — calibrated Landolt C test, myopia reference, eye-drop reminders. Kotlin + Compose.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages