Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,23 @@

## Project status

This repository has an Android infrastructure foundation, not production application code. `infrastructure_plan.md` is the approved source of truth for platform, storage, quality, Docker, CI, and release decisions; revise it with `infra-planner` before changing those decisions.
This repository has an Android infrastructure foundation and a Compose morning-routine prototype. `infrastructure_plan.md` is the approved source of truth for platform, storage, quality, Docker, CI, and release decisions; revise it with `infra-planner` before changing those decisions. `docs/ui-design.md` records the intended alarm/sunlight/app-restriction flow and the current preview limitations.

## Repository map

- `app/` — Android module infrastructure and an intentionally entrypoint-free manifest; product source is not created yet.
- `app/` — Android module, SunlightActivity entrypoint, Compose UI, camera preview flow, and unit/instrumentation tests.
- `gradle/`, `build.gradle.kts`, `settings.gradle.kts` — Gradle toolchain and version catalog.
- `scripts/` — infrastructure-only smoke checks.
- `Dockerfile`, `.dockerignore` — reproducible command-line Android build environment; no services or production image.
- `.github/workflows/` — pull-request checks and guarded GitHub Release workflow.
- `README.md` and `infrastructure_plan.md` — developer setup and approved infrastructure decisions.
- `.agents/skills/` — project skills. No API/backend, application UI, or production test directories exist yet.
- `.agents/skills/` — project skills. No API/backend exists; alarms, app restrictions, and repeated sunlight checks are not implemented.

## Required workflow

Read `infrastructure_plan.md`, this file, and applicable `.agents/skills/*/SKILL.md` before changing files. Use `infra-planner` for infrastructure decisions, `infra-builder` for planned infrastructure, `frontend-ui-engineering` for UI, `test-driven-development` for behavior changes, `security-and-hardening` for untrusted input or secrets, `ci-cd-and-automation` for workflow changes, `documentation-and-adrs` for material decisions, `code-review-and-quality` before merging, and `git-workflow-and-versioning` for every change.

Do not add product behavior during infrastructure work, alter plan decisions without revising the plan, commit secrets/signing material/build output, or treat the placeholder Android manifest as an application.
Do not add product behavior during infrastructure work, alter plan decisions without revising the plan, or commit secrets/signing material/build output. Keep UI claims accurate: the current foreground preview is not an alarm or device lock.

## Verification

Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Sunlight

Sunlight is an Android Focus app that keeps a dim red, screen-awake session open until a user-selected morning time. It then uses live front-camera brightness analysis and runs a final 15-minute countdown after bright light is detected.
Sunlight is an Android morning-routine prototype. The intended experience is a wake-up alarm followed by a user-selected duration of regularly checked sunlight, with only user-allowed apps available until completion and a hold-to-unlock emergency exit. The current preview checks camera brightness once and then runs a timer; alarm ringing, app restrictions, and repeated sunlight checks are not implemented yet.

## Repository map

- `app/` — Android module configuration and an intentionally entrypoint-free manifest.
- `app/` — Android module, Compose UI, foreground camera preview flow, and tests.
- `gradle/`, `build.gradle.kts`, and `settings.gradle.kts` — Gradle wrapper and build configuration.
- `scripts/` — Infrastructure-only smoke test.
- `Dockerfile` — Reproducible command-line Android build environment.
Expand All @@ -31,9 +31,15 @@ No `.env` file or backend configuration is required. Do not commit signing keys;
| `docker build --tag android-build-env:local .` | Build the pinned Android development image. |
| `docker run --rm --user root -v "$PWD":/workspace -w /workspace android-build-env:local --no-daemon verify` | Run full verification in Docker. |

The app is self-contained: it has no accounts, backend, location collection, or network use. Camera frames are analyzed in memory only; it never records, stores, or transmits photos or video. A Focus session consumes battery because the screen stays on. Android system navigation remains available and the app provides a hold-to-end escape action; it is not a device lock.
The app is self-contained: it has no accounts, backend, location collection, or network use. Camera frames are analyzed in memory only; it never records, stores, or transmits photos or video. A routine preview consumes battery because the screen stays on. Android system navigation remains available and the app provides a hold-to-exit escape action; it is not a device lock.

On first use, select the local time when camera checking should begin. The app requests camera permission when Focus starts. After the configured time, move the phone into direct sunlight; a brightness heuristic starts a fixed 15-minute final countdown. Bright artificial light can also satisfy the heuristic.
On Home, edit your wake-up time and sunlight duration in the same morning panel, then select **Try morning routine**. Duration defaults to 15 minutes and can be set from 1–120 minutes; it is saved locally and cannot change mid-routine. The app waits until the configured wake-up time (tomorrow if it has passed) before requesting camera permission or opening the camera. Sustained bright light starts the selected timer, then the camera stops. Bright artificial light can also satisfy this heuristic. No alarm will ring.

Debug builds also show **Debug: simulate sunlight**. It starts an immediate, clearly marked test without changing the saved wake-up time or requesting camera permission. Simulated brightness readings pass through the same sustained-light detector and timer transition as real camera frames. Use a one-minute duration to try the whole countdown, completion, and hold-to-exit flow. The test button and its handler are disabled in non-debuggable builds.

During an active debug test, the **Time speed** slider adjusts simulated time from 1×–100×. Changes apply while dragging, preserving elapsed progress and affecting both light detection and the countdown. Each new test starts at 1×; normal routines always use real time. At 100×, a 15-minute countdown takes about nine real seconds.

The bottom navigation is Home, Blocked apps, Alarms, and Settings, with Settings at the far right. The latter three pages are intentionally blank apart from their titles. Navigation stays visible and the hold-to-exit control remains accessible on every page during an active preview. The preview retains a black background, red text, and dimmed display. See [UI design notes](docs/ui-design.md) for the intended flow, current limitations, and UI test command.

## GitHub release setup

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package edu.sunlight.screentime

import android.view.KeyEvent
import android.widget.NumberPicker
import android.widget.SeekBar
import android.widget.TimePicker
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.withText
import java.time.LocalTime
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class MorningRoutineFlowTest {
@get:Rule val compose = createAndroidComposeRule<SunlightActivity>()

@Test
fun durationSavesSurvivesRecreationAndDebugUsesIt() {
compose.onNodeWithTag("sunlight-duration").performScrollTo().performClick()
onView(isAssignableFrom(NumberPicker::class.java)).inRoot(isDialog()).check { view, error ->
if (error != null) throw error
(view as NumberPicker).value = 1
}
onView(withText("Save")).inRoot(isDialog()).perform(click())
compose.onNodeWithText("1 min").assertIsDisplayed()
// Reopening verifies the value shown by the actual duration editor.
compose.onNodeWithTag("sunlight-duration").performClick()
onView(isAssignableFrom(NumberPicker::class.java)).inRoot(isDialog()).check { view, error ->
if (error != null) throw error
assertEquals(1, (view as NumberPicker).value)
view.value = 40
}
onView(withText("Cancel")).inRoot(isDialog()).perform(click())
compose.onNodeWithText("1 min").assertIsDisplayed()
compose.activityRule.scenario.recreate()
compose.waitUntil(5_000) {
compose.onAllNodesWithText("1 min").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("1 min").assertIsDisplayed()
compose.onNodeWithText("Debug: simulate sunlight").performScrollTo().performClick()
compose.onNodeWithText("DEBUG TEST · SIMULATED LIGHT").performScrollTo().assertIsDisplayed()
compose.waitUntil(8_000) {
compose.onAllNodesWithTag("routine-timer").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithTag("routine-timer").performScrollTo().assertIsDisplayed()
compose
.onNodeWithText("Simulated sunlight detected. Your 1-minute timer has started.")
.performScrollTo()
.assertIsDisplayed()
compose.onNodeWithText("Allow camera").assertDoesNotExist()
compose.onNodeWithText("Hold to exit early").performTouchInput { longClick() }
}

@Test
fun startingBeforeWakeUpWaitsWithoutRequestingCamera() {
val future = LocalTime.now().plusMinutes(10)
compose.onNodeWithTag("wake-up-time").performScrollTo().performClick()
onView(isAssignableFrom(TimePicker::class.java)).inRoot(isDialog()).check { view, error ->
if (error != null) throw error
(view as TimePicker).apply {
hour = future.hour
minute = future.minute
}
}
onView(withText("Set time")).inRoot(isDialog()).perform(click())
compose.onNodeWithText("Try morning routine").performScrollTo().performClick()
compose.onNodeWithText("Waiting for wake-up time.").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Allow camera").assertDoesNotExist()
compose.onNodeWithText("Debug: simulate sunlight").assertDoesNotExist()
compose.onNodeWithTag("debug-speed").assertDoesNotExist()
compose.onNodeWithText("Hold to exit early").performTouchInput { longClick() }
}

@Test
fun speedSliderChangesLiveAndAcceleratesCompletion() {
compose.onNodeWithTag("sunlight-duration").performScrollTo().performClick()
onView(isAssignableFrom(NumberPicker::class.java)).inRoot(isDialog()).check { view, error ->
if (error != null) throw error
(view as NumberPicker).value = 1
}
onView(withText("Save")).inRoot(isDialog()).perform(click())
compose.onNodeWithText("Debug: simulate sunlight").performScrollTo().performClick()
compose.onNodeWithText("Time speed: 1×").performScrollTo().assertIsDisplayed()
changeSpeedWithKeys(24)
compose.onNodeWithText("Time speed: 25×").assertIsDisplayed()
changeSpeedWithKeys(-24)
compose.onNodeWithText("Time speed: 1×").assertIsDisplayed()
changeSpeedWithKeys(99)
compose.waitUntil(12_000) {
compose.onAllNodesWithText("Morning routine complete.").fetchSemanticsNodes().isNotEmpty()
}
compose.onNodeWithText("Morning routine complete.").performScrollTo().assertIsDisplayed()
compose.onNodeWithTag("debug-speed").assertDoesNotExist()
compose.onNodeWithText("Debug: simulate sunlight").performScrollTo().performClick()
compose.onNodeWithText("Time speed: 1×").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Hold to exit early").performTouchInput { longClick() }
}

private fun changeSpeedWithKeys(delta: Int) {
onView(isAssignableFrom(SeekBar::class.java)).check { view, error ->
if (error != null) throw error
val bar = view as SeekBar
val key = if (delta > 0) KeyEvent.KEYCODE_DPAD_RIGHT else KeyEvent.KEYCODE_DPAD_LEFT
repeat(kotlin.math.abs(delta)) { bar.onKeyDown(key, KeyEvent(KeyEvent.ACTION_DOWN, key)) }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package edu.sunlight.screentime

import android.widget.TimePicker
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.RootMatchers.isDialog
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.withText
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Rule
import org.junit.Test

class ReleaseTimePickerTest {
@get:Rule val compose = createAndroidComposeRule<SunlightActivity>()

@Test
fun selectingAfternoonTimeAndCancellingAnEditPreservesSelection() {
openPicker()
onView(isAssignableFrom(TimePicker::class.java)).inRoot(isDialog()).check { view, error ->
if (error != null) throw error
val picker = view as TimePicker
assertFalse(picker.is24HourView)
picker.hour = 13
picker.minute = 5
}
onView(withText("Set time")).inRoot(isDialog()).perform(click())
compose.onNodeWithText("1:05 PM").performScrollTo().assertIsDisplayed()

openPicker()
onView(isAssignableFrom(TimePicker::class.java)).inRoot(isDialog()).check { view, error ->
if (error != null) throw error
val picker = view as TimePicker
assertEquals(13, picker.hour)
assertEquals(5, picker.minute)
picker.hour = 0
}
onView(withText("Cancel")).inRoot(isDialog()).perform(click())
compose.onNodeWithText("1:05 PM").performScrollTo().assertIsDisplayed()
}

private fun openPicker() {
compose.onNodeWithText("Wake-up time").performScrollTo().performClick()
}
}
154 changes: 154 additions & 0 deletions app/src/androidTest/java/edu/sunlight/screentime/SunlightScreenTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package edu.sunlight.screentime

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTouchInput
import java.time.Instant
import java.time.LocalTime
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test

class SunlightScreenTest {
@get:Rule val compose = createComposeRule()

@Test
fun setupKeepsTimePickerAndStartAccessible() {
var timeChoices = 0
var starts = 0
compose.setContent {
SunlightScreen(screenState(FocusPhase.SETUP), { timeChoices++ }, { starts++ }, {})
}
compose.onNodeWithTag("wake-up-time").performScrollTo().performClick()
compose.onNodeWithText("Try morning routine").performScrollTo().performClick()
compose.runOnIdle {
assertEquals(1, timeChoices)
assertEquals(1, starts)
}
}

@Test
fun activeSessionRequiresHoldToEnd() {
var ends = 0
compose.setContent { SunlightScreen(screenState(FocusPhase.WAITING), {}, {}, { ends++ }) }
val end = compose.onNodeWithText("Hold to exit early")
end.performClick()
compose.runOnIdle { assertEquals(0, ends) }
end.performTouchInput { longClick() }
compose.runOnIdle { assertEquals(1, ends) }
compose.onNodeWithText("Try morning routine").assertDoesNotExist()
compose.onNodeWithText("Change").assertDoesNotExist()
}

@Test
fun sunlightCheckShowsCameraGuidance() {
compose.setContent { SunlightScreen(screenState(FocusPhase.SEEKING_SUNLIGHT), {}, {}, {}) }
compose.onNodeWithText("Find the morning light.").assertIsDisplayed()
compose
.onNodeWithText("Keep the front camera uncovered.", substring = true)
.performScrollTo()
.assertIsDisplayed()
}

@Test
fun finalCountdownShowsRemainingTime() {
compose.setContent { SunlightScreen(screenState(FocusPhase.FINAL_COUNTDOWN), {}, {}, {}) }
compose.onNodeWithText("14:59").assertIsDisplayed()
compose
.onNodeWithText(
"Preview: the camera is off during this timer. Repeated sunlight checks are not available yet."
)
.performScrollTo()
.assertIsDisplayed()
}

@Test
fun completedSessionCanStartAgain() {
var starts = 0
compose.setContent { SunlightScreen(screenState(FocusPhase.COMPLETE), {}, { starts++ }, {}) }
compose.onNodeWithText("Morning routine complete.").assertIsDisplayed()
compose.onNodeWithText("Try morning routine again").performScrollTo().performClick()
compose.runOnIdle { assertEquals(1, starts) }
}

@Test
fun bottomNavigationOpensSeparatePagesAndReturnsHome() {
compose.setContent { SunlightScreen(screenState(FocusPhase.SETUP), {}, {}, {}) }
listOf("Blocked apps", "Alarms", "Settings").forEach { page ->
compose.onNodeWithTag("tab-$page").assertIsDisplayed().performClick().assertIsSelected()
compose.onNodeWithTag("page-$page").assertIsDisplayed()
compose.onNodeWithText("Try morning routine").assertDoesNotExist()
}
compose.onNodeWithTag("tab-Home").performClick().assertIsSelected()
compose.onNodeWithText("Wake-up time").performScrollTo().assertIsDisplayed()
}

@Test
fun earlyExitRemainsAvailableAwayFromHome() {
var ends = 0
compose.setContent {
SunlightScreen(screenState(FocusPhase.FINAL_COUNTDOWN), {}, {}, { ends++ })
}
compose.onNodeWithTag("tab-Settings").performClick()
compose.onNodeWithText("Hold to exit early").performTouchInput { longClick() }
compose.runOnIdle { assertEquals(1, ends) }
}

@Test
fun durationIsEditableAndNumberedStagesAreGone() {
var edits = 0
compose.setContent {
SunlightScreen(
screenState(FocusPhase.SETUP).copy(sunlightMinutes = 25),
{},
{},
{},
onChooseDuration = { edits++ },
)
}
compose.onNodeWithTag("sunlight-duration").performScrollTo().performClick()
compose.onNodeWithText("25 min").assertIsDisplayed()
compose.onNodeWithText("1 Wake up").assertDoesNotExist()
compose.onNodeWithText("2 Sunlight").assertDoesNotExist()
compose.runOnIdle { assertEquals(1, edits) }
}

@Test
fun debugActionIsExplicitAndAbsentFromReleaseUi() {
compose.setContent { SunlightScreen(screenState(FocusPhase.SETUP), {}, {}, {}) }
compose.onNodeWithText("Debug: simulate sunlight").assertDoesNotExist()
}

@Test
fun debugButtonStartsTheInjectedLightTest() {
var tests = 0
compose.setContent {
SunlightScreen(
screenState(FocusPhase.SETUP).copy(debugAvailable = true),
{},
{},
{},
onDebugSunlight = { tests++ },
)
}
compose.onNodeWithText("Debug: simulate sunlight").performScrollTo().performClick()
compose.runOnIdle { assertEquals(1, tests) }
}

private fun screenState(phase: FocusPhase): SunlightScreenState {
val now = Instant.parse("2026-10-21T06:00:00Z")
return SunlightScreenState(
releaseTime = LocalTime.of(7, 0),
phase = phase,
statusMessage = "Session status",
displayNow = now,
countdownEndsAt = now.plusSeconds(899),
)
}
}
Loading
Loading