Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.git
.github
.gradle
**/build
.idea
local.properties
*.iml
*.jks
*.keystore
secrets
.env
.env.*
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
55 changes: 55 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Pull request checks

on:
pull_request:

permissions:
contents: read

jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Make Gradle wrapper executable
run: chmod +x gradlew scripts/infrastructure-smoke.sh
- name: Verify formatting
run: ./gradlew --no-daemon ktfmtCheck
- name: Static analysis, unit tests, coverage, and debug package
run: ./gradlew --no-daemon verify
- name: Secret scan
uses: gitleaks/gitleaks-action@v2
- name: Upload reports on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: quality-reports
path: |
**/build/reports/
**/build/test-results/
if-no-files-found: ignore

codeql:
name: CodeQL
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v6
- uses: github/codeql-action/init@v4
with:
languages: java-kotlin
- uses: github/codeql-action/autobuild@v4
- uses: github/codeql-action/analyze@v4

ui-tests:
runs-on: ubuntu-latest
if: ${{ false }}
steps:
- run: echo "UI tests are intentionally deferred until application-owned Compose UI tests exist."
41 changes: 41 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Build signed APK release

on:
push:
tags: ["v*"]
workflow_dispatch:

permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
environment: release
steps:
- uses: actions/checkout@v6
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Make Gradle wrapper executable
run: chmod +x gradlew
- name: Verify project
run: ./gradlew --no-daemon verify
- name: Validate release prerequisites
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_PASSWORD"
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/release.keystore"
- name: Build signed APK
run: |
echo "Release signing must be wired into the application module before publishing."
exit 1
26 changes: 26 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Android/Gradle build output and local IDE state
.gradle/
build/
**/build/
.idea/
*.iml
local.properties

# Local Android SDK and emulator artifacts
.android/
captures/
*.keystore
*.jks

# Reports and logs
reports/
*.log

# Environment files and release material
.env
.env.*
!.env.example
secrets/

# Operating system files
.DS_Store
2 changes: 2 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[extend]
useDefault = true
32 changes: 32 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Agent Guidance

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

## Repository map

- `app/` — Android module infrastructure and an intentionally entrypoint-free manifest; product source is not created yet.
- `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.

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

## Verification

Run `./gradlew --no-daemon ktfmtCheck`, `./gradlew --no-daemon verify`, and `./scripts/infrastructure-smoke.sh`. Docker validation uses `docker build --tag android-build-env:local .`; it does not replace Android Studio, an emulator/device, or release signing. Remove locally built images only if explicitly requested.

## Change checklist

1. Keep changes scoped and update relevant documentation.
2. Run the applicable verification commands after each increment.
3. Check `git diff --check` and inspect the staged diff before committing.
4. Update README and this file when commands, paths, or developer prerequisites change.
32 changes: 32 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
FROM gradle:9.6.0-jdk17

USER root

ARG ANDROID_COMMAND_LINE_TOOLS_VERSION=15859902
ARG ANDROID_COMMAND_LINE_TOOLS_SHA256=4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583
ENV ANDROID_HOME=/opt/android-sdk
ENV ANDROID_SDK_ROOT=/opt/android-sdk
ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools

RUN apt-get update \
&& apt-get install --no-install-recommends -y curl unzip \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p "${ANDROID_HOME}/cmdline-tools" \
&& curl --fail --location --silent --show-error \
--output /tmp/cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_COMMAND_LINE_TOOLS_VERSION}_latest.zip" \
&& echo "${ANDROID_COMMAND_LINE_TOOLS_SHA256} /tmp/cmdline-tools.zip" | sha256sum --check \
&& unzip -q /tmp/cmdline-tools.zip -d /tmp \
&& mv /tmp/cmdline-tools "${ANDROID_HOME}/cmdline-tools/latest" \
&& rm /tmp/cmdline-tools.zip \
&& yes | sdkmanager --sdk_root="${ANDROID_HOME}" --licenses \
&& sdkmanager --sdk_root="${ANDROID_HOME}" \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0"

WORKDIR /workspace
COPY --chown=gradle:gradle . /workspace
USER gradle

ENTRYPOINT ["./gradlew"]
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,47 @@
# TechStartup-Template
# 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.

## Repository map

- `app/` — Android module configuration and an intentionally entrypoint-free manifest.
- `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.
- `.github/workflows/` — Pull-request and guarded release workflows.
- `infrastructure_plan.md` — Selected infrastructure decisions.
- `AGENTS.md` — Contributor and agent guidance.

## Getting Started

1. Install [Git](https://git-scm.com/downloads), [Android Studio](https://developer.android.com/studio), and Docker Desktop/Engine if using Docker. Install Android API 36, Build Tools 36.0.0, an API 36 emulator image, and use JDK 17.
2. Open the repository in Android Studio and set the Gradle JDK to 17.
3. Run `chmod +x gradlew scripts/infrastructure-smoke.sh`.
4. Run `./gradlew --no-daemon verify` after Android SDK setup, or use Docker below.

No `.env` file or backend configuration is required. Do not commit signing keys; local SDK paths belong in ignored `local.properties`.

## Commands

| Command | Purpose |
|---|---|
| `./gradlew --no-daemon ktfmtCheck` | Verify Kotlin and Gradle-script formatting. |
| `./gradlew --no-daemon verify` | Run static checks, test harness, coverage configuration, and debug package validation. |
| `./scripts/infrastructure-smoke.sh` | Confirm the wrapper, workflows, Dockerfile, and Gradle configuration load. |
| `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.

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.

## GitHub release setup

Create a protected `release` environment with `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEY_ALIAS`, `ANDROID_KEYSTORE_PASSWORD`, and `ANDROID_KEY_PASSWORD` secrets. The release workflow intentionally stops before publishing until a later application task wires secure APK signing into the module.

## Troubleshooting

- **Android SDK missing:** Install API 36 and Build Tools 36.0.0 with Android Studio’s SDK Manager, then create ignored `local.properties` containing `sdk.dir=/absolute/path/to/Android/Sdk`.
- **Wrong Java version:** Set Android Studio’s Gradle JDK or `JAVA_HOME` to JDK 17.
- **Docker unavailable:** Start Docker Desktop/Engine, then rerun the Docker build command.
- **First Docker verification is slow:** Gradle downloads Android and Compose dependencies on its first run; allow several minutes.
62 changes: 62 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.detekt)
alias(libs.plugins.ktfmt)
}

android {
namespace = "edu.sunlight.screentime"
compileSdk = 36

defaultConfig {
applicationId = "edu.sunlight.screentime"
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes { release { isMinifyEnabled = false } }

buildFeatures { compose = true }
}

dependencies {
val composeBom = platform(libs.androidx.compose.bom)
implementation(composeBom)
androidTestImplementation(composeBom)

implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.lifecycle.runtime.ktx)
debugImplementation(libs.androidx.compose.ui.tooling)

testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}

detekt {
buildUponDefaultConfig = true
allRules = false
}

tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
jvmTarget = "17"
val javaVersion = System.getProperty("java.version")
enabled = !javaVersion.startsWith("25")
}

tasks.withType<io.gitlab.arturbosch.detekt.DetektCreateBaselineTask>().configureEach {
jvmTarget = "17"
val javaVersion = System.getProperty("java.version")
enabled = !javaVersion.startsWith("25")
}
16 changes: 16 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.front" android:required="true" />

<application android:label="Sunlight" android:theme="@style/Theme.Sunlight">
<activity
android:name=".SunlightActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
14 changes: 14 additions & 0 deletions app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package edu.sunlight.screentime

import java.time.Instant
import java.time.LocalTime
import java.time.ZoneId

internal object SessionPlanner {
fun nextRelease(now: Instant, releaseTime: LocalTime, zone: ZoneId): Instant {
val today = now.atZone(zone).toLocalDate()
val candidate = today.atTime(releaseTime).atZone(zone).toInstant()
return if (candidate.isAfter(now)) candidate
else today.plusDays(1).atTime(releaseTime).atZone(zone).toInstant()
}
}
Loading
Loading