From 0170db715613c4731b5e58b2bcd6bad500db1e79 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 16 Sep 2026 13:49:02 -0700 Subject: [PATCH 1/6] created app infrastructure with codex --- .dockerignore | 12 ++ .github/dependabot.yml | 10 + .github/workflows/pr-checks.yml | 55 +++++ .github/workflows/release.yml | 41 ++++ .gitignore | 26 +++ .gitleaks.toml | 2 + AGENTS.md | 32 +++ Dockerfile | 32 +++ README.md | 46 ++++- app/build.gradle.kts | 45 ++++ app/src/main/AndroidManifest.xml | 4 + build.gradle.kts | 12 ++ detekt.yml | 8 + gradle.properties | 5 + gradle/libs.versions.toml | 28 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 248 +++++++++++++++++++++++ gradlew.bat | 82 ++++++++ infrastructure_plan.md | 173 ++++++++++++++++ scripts/infrastructure-smoke.sh | 17 ++ settings.gradle.kts | 18 ++ 22 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/pr-checks.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .gitleaks.toml create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 app/build.gradle.kts create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 build.gradle.kts create mode 100644 detekt.yml create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 infrastructure_plan.md create mode 100755 scripts/infrastructure-smoke.sh create mode 100644 settings.gradle.kts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b9531e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.gradle +**/build +.idea +local.properties +*.iml +*.jks +*.keystore +secrets +.env +.env.* diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..10ef831 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..904e6df --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -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 + build-mode: none + - 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." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5f13e2f --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9226ec9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..5474857 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,2 @@ +[extend] +useDefault = true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6f603dc --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..acc6e39 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 982a73e..888f641 100644 --- a/README.md +++ b/README.md @@ -1 +1,45 @@ -# TechStartup-Template \ No newline at end of file +# Sunlight + +Android infrastructure is ready for application development; production application code and user-facing screens have not been created yet. + +## 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 test commands currently validate the harness only: application code and UI tests have intentionally not been added. + +## 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..46042ff --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,45 @@ +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.datastore.preferences) + 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 +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..730af7b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ef56caf --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.ktfmt) apply false +} + +tasks.register("verify") { + group = "verification" + description = "Runs the infrastructure quality gates available before application code exists." + dependsOn(":app:check", ":app:assembleDebug") +} diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..a487e97 --- /dev/null +++ b/detekt.yml @@ -0,0 +1,8 @@ +build: + maxIssues: 0 + +config: + validation: true + +processors: + active: true diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..afad3a4 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.configuration-cache=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..e71a9bd --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,28 @@ +[versions] +agp = "9.4.0" +kotlin = "2.3.21" +composeBom = "2025.08.00" +datastore = "1.2.1" +junit = "4.13.2" +androidxJunit = "1.3.0" +espresso = "3.7.0" +detekt = "1.23.8" +ktfmt = "0.27.0" + +[libraries] +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } +ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..eb84db6 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/infrastructure_plan.md b/infrastructure_plan.md new file mode 100644 index 0000000..5140ea0 --- /dev/null +++ b/infrastructure_plan.md @@ -0,0 +1,173 @@ +# Infrastructure Plan + +> Planning only. This document describes future infrastructure work. No installations, configuration changes, containers, workflows, deployments, or other implementation files were created by the infrastructure-planning process. + +## 1. Project and User Experience + +- **Application:** Android-only device-centered application. +- **Primary users:** Individual mobile users. +- **Primary user task:** Uses Android-native device capabilities in an installed mobile application. +- **Selected platform:** Native Android. +- **User-experience rationale:** Native Android supplies direct, well-supported access to Android capabilities. +- **Required operating systems, browsers, or devices:** Android devices; an Android emulator is suitable for development and automated UI tests. +- **Offline or native-device requirements:** Self-contained operation; native Android device capability access; no account or backend. + +## 2. Connectivity and Application Shape + +- **Connectivity model:** Self-contained. +- **Accounts and authentication:** None. +- **Backend required:** No. +- **Cross-device persistence:** No; data remains on the device. +- **Interaction between accounts:** None. +- **Primary application components:** Jetpack Compose UI, Kotlin application logic, Android device integrations, and DataStore preferences. + +## 3. Selected Technology Stack + +| Area | Selected technology | Purpose | Version policy | +|---|---|---|---| +| Primary language | Kotlin | Android application code | Current stable Android-supported Kotlin release | +| Application framework | Jetpack Compose | Declarative native Android UI | Current stable AndroidX release compatible with Kotlin | +| Runtime or SDK | Android SDK | Platform APIs, build tools, emulator, and device tooling | Stable API level and build tools supported by Android Studio | +| Package manager | Gradle with version catalog | Resolves and locks JVM/Android dependencies | Gradle version supported by Android Gradle Plugin | +| Build or packaging tool | Android Gradle Plugin | Debug/release APK builds | Stable version compatible with selected Gradle/Kotlin | + +## 4. Storage and Persistence + +- **Storage model:** Local. +- **Primary data store:** Jetpack DataStore Preferences for small settings, flags, and simple saved state. +- **User files or object storage:** None planned. +- **Local-development storage:** App-private DataStore on the emulator or test device. +- **Production hosting model:** None. +- **Schema and migration approach:** Use DataStore preference keys; provide explicit one-time migration code if a persisted key is renamed or format changes. +- **Backup, export, or recovery approach:** Android device backup behavior is not relied on; clearing app data resets local preferences. +- **Secrets and connection-string approach:** No backend connection strings or application secrets; do not hard-code any future third-party credentials. +- **Reason this storage fits the access pattern:** The confirmed scope only needs small, device-local preferences and has no sharing or synchronization. + +## 5. Testing Tools + +| Test layer | Tool or library | Planned scope | Planned execution point | +|---|---|---|---| +| Unit | JUnit with Kotlin test support | Application logic and DataStore-adjacent behavior | Local and pull requests | +| Integration | AndroidX Test | Android framework interactions where needed | Local and pull requests | +| End-to-end or UI | Jetpack Compose UI Test on an emulator | Important screen interactions and primary user flows | Pull requests and release validation | + +## 6. Test Analysis + +| Capability | Tool | Planned policy | +|---|---|---| +| Coverage | JaCoCo | Collect unit-test coverage in CI | +| Coverage threshold or regression rule | JaCoCo verification | Block pull requests when coverage falls below a modest baseline agreed before implementation | +| Flaky-test or duration analysis | Gradle test reports and GitHub Actions artifacts | Review failures and slow/flaky tests from CI artifacts | +| Reporting | GitHub Actions summary and uploaded JaCoCo report | Preserve coverage report when checks fail | + +## 7. Static Analysis and Security + +| Check | Tool | Planned enforcement | +|---|---|---| +| Formatting | ktfmt | Verify on pull requests; developers may format locally | +| Linting | Android Lint | Block pull requests on applicable errors | +| Type checking or compiler warnings | Kotlin compiler and Android Gradle Plugin | Treat new warnings according to project policy; compilation must pass | +| Anti-pattern or maintainability analysis | Detekt | Block pull requests on agreed ruleset violations | +| Dependency vulnerability scanning | Dependabot | Open update pull requests; review before merge | +| Secret scanning | Gitleaks | Block pull requests when committed secrets are detected | +| Static security analysis | CodeQL for Java/Kotlin | Run on pull requests and scheduled scans; address actionable findings | + +## 8. Development Technologies Requiring Manual Installation + +These are developer-workstation prerequisites that will not be supplied by the planned Docker environment. + +| Technology | Why it is needed | Required on which machines | Version policy | Planned installation or verification method | Why Docker does not provide it | +|---|---|---|---|---|---| +| Git | Source control and GitHub workflow | All developer and CI environments | Supported current stable release | Future workstation setup documentation | Host credential integration and normal developer workflow | +| Android Studio | IDE, Android SDK manager, emulator management, and debugging | Developer workstations | Current stable release | Future Android developer setup documentation | Native IDE and emulator management | +| Android SDK and platform/build tools | Compile, package, debug, and test Android app | Developer workstations and Android CI runners | API/build-tools versions selected by project | Future SDK Manager setup | Emulator/device tooling needs host integration | +| JDK | Runs Gradle and Android build tools | Developer workstations and CI | Android Gradle Plugin-supported LTS JDK | Future documented setup or Android Studio bundle verification | Native build/IDE integration | +| Android emulator or physical device | Compose UI test and device-capability validation | Developer workstations | Matching supported Android API level | Future AVD/device setup | Hardware acceleration and USB/device access | +| Android signing keystore tools | Sign release APK | Authorized release workstation/CI only | Compatible Android SDK tooling | Future secure release setup | Signing material must remain protected outside images | +| Docker Desktop or Docker Engine | Reproducible command-line build environment | Developers using the container workflow | Supported stable release | Future Docker setup documentation | Docker itself is the host runtime | + +### Host tools intentionally not required + +- **Not required because Docker supplies them:** The selected container image will supply its pinned command-line JDK, Gradle environment, and Android command-line SDK components. +- **Not required for this platform:** iOS/Xcode tooling, backend runtime, database server, and production container host. + +## 9. Docker Plan + +- **Planned Docker role:** Reproducible command-line build environment. +- **Future files that would be created during implementation:** `Dockerfile`, `.dockerignore`, and optional Compose file only if a local developer wrapper proves useful. +- **Planned images and services:** One Android command-line build image; no database or backend services. +- **Development container behavior:** Run Gradle compile, unit-test, static-analysis, and packaging validation tasks with pinned SDK/JDK tooling; Compose UI tests remain on native Android runners with an emulator. +- **Ports:** None. +- **Bind mounts and named volumes:** Bind-mount source for local checks; use named Gradle caches where safe; never mount signing keys by default. +- **Environment-variable and secret handling:** Pass only non-secret build settings; use CI secrets for release signing and never embed them in images. +- **Local database or service containers:** None. +- **Production image or non-container release path:** Build a signed Android APK natively or in CI, then attach it to a GitHub Release; no production container. +- **Build stages and hardening:** Use a minimal pinned build image, non-root user where compatible, `.dockerignore`, no embedded secrets, and no claim that Docker replaces Android emulators or signing environments. +- **Planned future development command:** `docker build -t android-build-env .` and an explicitly documented `docker run` Gradle command. +- **Planned future production or packaging command:** A documented Gradle release-APK task executed only after signing configuration exists. + +## 10. GitHub Actions Plan + +### A. Automated pull-request checks + +- **Future workflow file:** `.github/workflows/pr-checks.yml` +- **Trigger:** `pull_request`. +- **Runner or matrix:** Ubuntu latest for Kotlin/JVM checks; Android-capable Linux runner with hardware acceleration for Compose UI tests when practical. +- **Permissions:** Read-only `contents`; grant only the minimum additional permissions required by CodeQL or artifact upload. +- **Planned jobs in order:** + 1. Checkout, install the pinned JDK, restore Gradle cache, and perform lockfile-enforced dependency resolution. + 2. Verify ktfmt; run Android Lint, Detekt, Kotlin compilation, unit tests, and JaCoCo coverage verification. + 3. Build a debug APK for package validation. + 4. Run Compose UI tests on an emulator when runner capacity permits; upload test reports, screenshots, logs, and traces on failure. + 5. Run Gitleaks and CodeQL; Dependabot supplies dependency-update pull requests separately. +- **Service containers:** None. +- **Caching:** Cache Gradle wrapper and dependency caches keyed by Gradle files and lockfiles; do not cache secrets or release artifacts. +- **Coverage and analysis reporting:** Upload JaCoCo HTML/XML reports and job summaries; enforce the agreed modest threshold. +- **Failure artifacts:** JaCoCo report, Gradle test reports, Android Lint/Detekt reports, emulator logs, and UI-test screenshots/traces when available. +- **Checks that should block merging:** Formatting, compilation, Android Lint, Detekt, unit tests, coverage threshold, debug build, Gitleaks, and applicable CodeQL findings; UI tests when configured as required. +- **Proposed branch-protection settings:** Require the listed status checks, at least one approving review, up-to-date branches, and no force pushes to the protected branch. + +### B. New-release deployment + +- **Future workflow file:** `.github/workflows/release.yml` +- **Release trigger:** A pushed `v*` tag, with `workflow_dispatch` retained for a controlled retry. +- **Release destination:** GitHub Releases with signed APK artifacts. +- **Runner or matrix:** Ubuntu latest with Android SDK/JDK setup; use a protected release environment. +- **Planned jobs in order:** + 1. Checkout the tag and rerun formatting, analysis, unit tests, coverage verification, UI tests where practical, and release build validation. + 2. Retrieve protected signing material, build and verify the signed release APK, then generate checksums. + 3. Create or update the GitHub Release, attach the signed APK and checksum, publish release notes, and retain artifacts. +- **Build artifacts:** Signed release APK and SHA-256 checksum file. +- **Signing, notarization, or store requirements:** Android signing keystore, key alias, and protected passwords; no notarization or store credentials for GitHub Release distribution. +- **Database migration step:** None. +- **Environment approval:** Require an authorized maintainer approval through a protected GitHub `release` environment. +- **Post-deployment verification:** Download the release artifact and validate its checksum; manually install on a test Android device before announcing broadly. +- **Failed-release or rollback approach:** Mark the GitHub Release as draft/pre-release or remove the affected asset, publish a corrected signed version with a new tag, and communicate the replacement version. + +### GitHub configuration required later + +| Name | Type | Purpose | +|---|---|---| +| `ANDROID_KEYSTORE_BASE64` | Secret | Base64-encoded Android release keystore for the release job | +| `ANDROID_KEY_ALIAS` | Secret | Alias of the release-signing key | +| `ANDROID_KEYSTORE_PASSWORD` | Secret | Password to unlock the keystore | +| `ANDROID_KEY_PASSWORD` | Secret | Password for the signing key | +| `release` | GitHub environment | Protects publishing with reviewer approval and scoped secrets | +| GitHub Actions token | Token | Creates the GitHub Release and uploads artifacts using least privilege | + +## 11. Planned Repository Artifacts - Not Created by This Skill + +- [ ] Application manifest or project file: Gradle settings, app module, and Android manifest. +- [ ] Lockfile: Gradle dependency locking files or version-catalog lock configuration. +- [ ] Test configuration: JUnit, AndroidX Test, Compose UI Test, and JaCoCo configuration. +- [ ] Static-analysis configuration: ktfmt, Detekt, Android Lint, Gitleaks, and CodeQL settings. +- [ ] Docker or Compose files: `Dockerfile`, `.dockerignore`, and optional Compose wrapper. +- [ ] `.github/workflows/pr-checks.yml`: Pull-request validation workflow. +- [ ] `.github/workflows/release.yml`: Signed APK GitHub Release workflow. +- [ ] Deployment or store configuration: Release signing configuration and GitHub environment setup. + +## 12. Assumptions and Open Items + +- **Assumptions:** The app’s saved state remains small enough for DataStore Preferences; it does not need media/file storage, accounts, synchronization, or backend services. +- **Decisions still requiring an external account, credential, certificate, or organizational approval:** An authorized GitHub maintainer must create and protect the Android signing keystore and release-environment secrets. +- **Items to confirm before implementation begins:** Target/minimum Android API levels, specific device capabilities, the JaCoCo coverage baseline, final APK package/application ID, and whether UI tests are required on every pull request. diff --git a/scripts/infrastructure-smoke.sh b/scripts/infrastructure-smoke.sh new file mode 100755 index 0000000..815f508 --- /dev/null +++ b/scripts/infrastructure-smoke.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root_dir" + +test -x ./gradlew +test -f gradle/wrapper/gradle-wrapper.jar +test -f .github/workflows/pr-checks.yml +test -f .github/workflows/release.yml +test -f Dockerfile +test -f .gitleaks.toml + +./gradlew --no-daemon help >/dev/null +./gradlew --no-daemon :app:tasks --group verification >/dev/null + +echo "Infrastructure smoke test passed. It validates tooling only; no product behavior exists yet." diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..baee03d --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "sunlight" +include(":app") From 7d7b373602e831a0a4ddf18a685b4b1025c04a15 Mon Sep 17 00:00:00 2001 From: James Date: Thu, 8 Oct 2026 16:51:37 -0700 Subject: [PATCH 2/6] used codex to create basic app --- README.md | 6 +- app/build.gradle.kts | 5 + app/src/main/AndroidManifest.xml | 15 +- .../edu/sunlight/screentime/SessionPlanner.kt | 15 + .../sunlight/screentime/SunlightActivity.kt | 349 ++++++++++++++++++ app/src/main/res/values/styles.xml | 7 + .../sunlight/screentime/SessionPlannerTest.kt | 27 ++ .../ADR-001-sunlight-focus-camera.md | 17 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 8 + 10 files changed, 457 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt create mode 100644 app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/test/java/edu/sunlight/screentime/SessionPlannerTest.kt create mode 100644 docs/decisions/ADR-001-sunlight-focus-camera.md create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/README.md b/README.md index 888f641..3a3e765 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Sunlight -Android infrastructure is ready for application development; production application code and user-facing screens have not been created yet. +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 @@ -31,7 +31,9 @@ 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 test commands currently validate the harness only: application code and UI tests have intentionally not been added. +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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 46042ff..09fb01b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,7 +29,12 @@ dependencies { 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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 730af7b..dbe097d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,4 +1,15 @@ - - + + + + + + + + + + + diff --git a/app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt b/app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt new file mode 100644 index 0000000..b9b7b81 --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt @@ -0,0 +1,15 @@ +package edu.sunlight.screentime + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +internal object SessionPlanner { + fun nextRelease(now: Instant, releaseTime: LocalTime, zone: ZoneId): Instant { + val today = LocalDate.ofInstant(now, zone) + val candidate = today.atTime(releaseTime).atZone(zone).toInstant() + return if (candidate.isAfter(now)) candidate + else today.plusDays(1).atTime(releaseTime).atZone(zone).toInstant() + } +} diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt b/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt new file mode 100644 index 0000000..f399dcd --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt @@ -0,0 +1,349 @@ +package edu.sunlight.screentime + +import android.Manifest +import android.app.TimePickerDialog +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.lifecycle.lifecycleScope +import java.nio.ByteBuffer +import java.time.Duration +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +private val android.content.Context.focusDataStore by preferencesDataStore(name = "focus_settings") +private val releaseHourKey = intPreferencesKey("release_hour") +private val releaseMinuteKey = intPreferencesKey("release_minute") + +class SunlightActivity : ComponentActivity() { + private var releaseTime by mutableStateOf(null) + private var phase by mutableStateOf(FocusPhase.SETUP) + private var statusMessage by mutableStateOf("Choose the time to begin checking for sunlight.") + private var sessionTarget: Instant? = null + private var countdownEndsAt: Instant? = null + private var brightSince: Instant? = null + private var cameraProvider: ProcessCameraProvider? = null + private var displayNow by mutableStateOf(Instant.now()) + + private val cameraPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) startSession() + else statusMessage = "Camera permission is required to begin a Focus session." + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + val preferences = focusDataStore.data.first() + val hour = preferences[releaseHourKey] + val minute = preferences[releaseMinuteKey] + if (hour != null && minute != null) releaseTime = LocalTime.of(hour, minute) + } + setContent { SunlightScreen() } + tickClock() + } + + override fun onResume() { + super.onResume() + if (phase != FocusPhase.SETUP && phase != FocusPhase.COMPLETE) hideSystemBars() + } + + override fun onDestroy() { + stopCamera() + super.onDestroy() + } + + private fun chooseReleaseTime() { + val current = releaseTime ?: LocalTime.of(7, 0) + TimePickerDialog( + this, + { _, hour, minute -> + releaseTime = LocalTime.of(hour, minute) + lifecycleScope.launch { + focusDataStore.edit { preferences -> + preferences[releaseHourKey] = hour + preferences[releaseMinuteKey] = minute + } + } + statusMessage = "Sunlight checking will begin at ${displayTime(releaseTime!!)}." + }, + current.hour, + current.minute, + android.text.format.DateFormat.is24HourFormat(this), + ) + .show() + } + + private fun beginFocus() { + if (releaseTime == null) { + statusMessage = "Choose a sunlight-check time first." + return + } + if ( + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) { + startSession() + } else { + cameraPermission.launch(Manifest.permission.CAMERA) + } + } + + private fun startSession() { + val now = Instant.now() + sessionTarget = + SessionPlanner.nextRelease(now, requireNotNull(releaseTime), ZoneId.systemDefault()) + phase = FocusPhase.WAITING + statusMessage = + "Focus is active. Camera checking begins at ${displayTime(requireNotNull(releaseTime))}." + enterFocusDisplay() + updateSession() + } + + private fun updateSession() { + val now = Instant.now() + when (phase) { + FocusPhase.WAITING -> if (!now.isBefore(sessionTarget)) startCamera() + FocusPhase.FINAL_COUNTDOWN -> if (!now.isBefore(countdownEndsAt)) finishSession() + else -> Unit + } + if (phase == FocusPhase.WAITING || phase == FocusPhase.FINAL_COUNTDOWN) { + window.decorView.postDelayed({ updateSession() }, 1_000) + } + } + + private fun startCamera() { + phase = FocusPhase.SEEKING_SUNLIGHT + statusMessage = "Move the phone into direct sunlight to finish the light check." + val providerFuture = ProcessCameraProvider.getInstance(this) + providerFuture.addListener( + { + val provider = providerFuture.get() + cameraProvider = provider + val analysis = + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer(ContextCompat.getMainExecutor(this)) { frame -> + try { + if (meanLuma(frame.planes[0].buffer) >= LIGHT_THRESHOLD) { + val now = Instant.now() + if (brightSince == null) brightSince = now + if (Duration.between(brightSince, now) >= REQUIRED_BRIGHT_DURATION) + sunlightDetected() + } else { + brightSince = null + } + } finally { + frame.close() + } + } + provider.unbindAll() + provider.bindToLifecycle(this, CameraSelector.DEFAULT_FRONT_CAMERA, analysis) + }, + ContextCompat.getMainExecutor(this), + ) + } + + private fun sunlightDetected() { + if (phase != FocusPhase.SEEKING_SUNLIGHT) return + stopCamera() + countdownEndsAt = Instant.now().plus(FINAL_FOCUS_DURATION) + phase = FocusPhase.FINAL_COUNTDOWN + statusMessage = "Sunlight detected. Your final 15-minute Focus countdown has begun." + updateSession() + } + + private fun finishSession() { + phase = FocusPhase.COMPLETE + statusMessage = "Focus complete. Good morning." + leaveFocusDisplay() + } + + private fun endSession() { + stopCamera() + phase = FocusPhase.SETUP + sessionTarget = null + countdownEndsAt = null + statusMessage = "Focus session ended." + leaveFocusDisplay() + } + + private fun stopCamera() { + cameraProvider?.unbindAll() + cameraProvider = null + brightSince = null + } + + private fun enterFocusDisplay() { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.attributes = window.attributes.apply { screenBrightness = 0.03f } + hideSystemBars() + } + + private fun leaveFocusDisplay() { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.attributes = + window.attributes.apply { + screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + WindowCompat.getInsetsController(window, window.decorView) + .show(WindowInsetsCompat.Type.systemBars()) + } + + private fun meanLuma(buffer: ByteBuffer): Int { + val duplicate = buffer.duplicate() + var total = 0L + var count = 0 + while (duplicate.hasRemaining()) { + total += duplicate.get().toInt() and 0xff + count += 1 + } + return if (count == 0) 0 else (total / count).toInt() + } + + private fun tickClock() { + displayNow = Instant.now() + window.decorView.postDelayed({ tickClock() }, 1_000) + } + + private fun hideSystemBars() { + WindowCompat.getInsetsController(window, window.decorView).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.systemBars()) + } + } + + @androidx.compose.runtime.Composable + private fun SunlightScreen() { + val active = + phase == FocusPhase.WAITING || + phase == FocusPhase.SEEKING_SUNLIGHT || + phase == FocusPhase.FINAL_COUNTDOWN + val foreground = if (active) NightRed else Color.White + Column( + modifier = + Modifier.fillMaxSize() + .background(if (active) Color.Black else Color(0xff171717)) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + BasicText( + currentClock(), + style = TextStyle(color = foreground, fontSize = 52.sp, fontWeight = FontWeight.Light), + ) + Spacer(Modifier.height(32.dp)) + BasicText( + statusMessage, + style = TextStyle(color = foreground, fontSize = 20.sp), + modifier = Modifier.semantics { contentDescription = statusMessage }, + ) + Spacer(Modifier.height(32.dp)) + if (!active) { + FocusAction("Choose release time", foreground, ::chooseReleaseTime) + Spacer(Modifier.height(16.dp)) + BasicText( + releaseTime?.let { "Check begins at ${displayTime(it)}" } ?: "No release time selected", + style = TextStyle(color = Color.LightGray, fontSize = 16.sp), + ) + Spacer(Modifier.height(24.dp)) + FocusAction("Start Focus", foreground, ::beginFocus) + } else { + if (phase == FocusPhase.FINAL_COUNTDOWN) { + BasicText( + "${remainingCountdown()} remaining", + style = TextStyle(color = foreground, fontSize = 18.sp), + ) + Spacer(Modifier.height(24.dp)) + } + BasicText( + "Hold to end session", + style = TextStyle(color = foreground, fontSize = 18.sp), + modifier = Modifier.combinedClickable(onClick = {}, onLongClick = ::endSession), + ) + } + } + } + + @androidx.compose.runtime.Composable + private fun FocusAction(label: String, color: Color, action: () -> Unit) { + BasicText( + label, + style = TextStyle(color = color, fontSize = 20.sp), + modifier = + Modifier.fillMaxWidth() + .padding(16.dp) + .combinedClickable(onClick = action, onLongClick = action), + ) + } + + private fun currentClock(): String = + displayNow + .atZone(ZoneId.systemDefault()) + .toLocalTime() + .format(DateTimeFormatter.ofPattern("HH:mm")) + + private fun remainingCountdown(): String { + val seconds = + countdownEndsAt?.let { Duration.between(displayNow, it).seconds.coerceAtLeast(0) } ?: 0 + return String.format("%02d:%02d", seconds / 60, seconds % 60) + } + + private fun displayTime(time: LocalTime): String = + time.format(DateTimeFormatter.ofPattern("HH:mm")) + + private enum class FocusPhase { + SETUP, + WAITING, + SEEKING_SUNLIGHT, + FINAL_COUNTDOWN, + COMPLETE, + } + + private companion object { + const val LIGHT_THRESHOLD = 220 + val REQUIRED_BRIGHT_DURATION: Duration = Duration.ofSeconds(3) + val FINAL_FOCUS_DURATION: Duration = Duration.ofMinutes(15) + val NightRed = Color(0xffff5c5c) + } +} diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..35ab0c8 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ + + + diff --git a/app/src/test/java/edu/sunlight/screentime/SessionPlannerTest.kt b/app/src/test/java/edu/sunlight/screentime/SessionPlannerTest.kt new file mode 100644 index 0000000..472a902 --- /dev/null +++ b/app/src/test/java/edu/sunlight/screentime/SessionPlannerTest.kt @@ -0,0 +1,27 @@ +package edu.sunlight.screentime + +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +class SessionPlannerTest { + @Test + fun `chooses tomorrow when the release time has passed`() { + val zone = ZoneId.of("UTC") + val result = + SessionPlanner.nextRelease(Instant.parse("2026-10-07T08:00:00Z"), LocalTime.of(7, 0), zone) + + assertEquals(Instant.parse("2026-10-08T07:00:00Z"), result) + } + + @Test + fun `chooses today when the release time remains`() { + val zone = ZoneId.of("UTC") + val result = + SessionPlanner.nextRelease(Instant.parse("2026-10-07T06:00:00Z"), LocalTime.of(7, 0), zone) + + assertEquals(Instant.parse("2026-10-07T07:00:00Z"), result) + } +} diff --git a/docs/decisions/ADR-001-sunlight-focus-camera.md b/docs/decisions/ADR-001-sunlight-focus-camera.md new file mode 100644 index 0000000..e668c22 --- /dev/null +++ b/docs/decisions/ADR-001-sunlight-focus-camera.md @@ -0,0 +1,17 @@ +# ADR-001: Use foreground CameraX brightness analysis for Focus completion + +## Status + +Accepted + +## Context + +Sunlight Focus must help a user avoid leaving the app overnight, begin checking after an editable local time, and finish only after the user moves the phone into bright sunlight. The app must remain self-contained and must not store camera media. + +## Decision + +Use a foreground activity with `FLAG_KEEP_SCREEN_ON`, a dim red Compose interface, and CameraX `ImageAnalysis` bound to the activity lifecycle. Analyze front-camera luminance in memory only, then stop the camera and run a fixed 15-minute final countdown once sustained brightness is detected. Store only the release-time preference in DataStore. + +## Consequences + +Android navigation cannot be permanently disabled, and screen-on behavior ends when the app leaves the foreground. The brightness heuristic can accept artificial light and requires the user to move the phone into direct sunlight. No camera preview, photo, video, location, account, or network data is collected. diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..fa4ed51 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e71a9bd..a304fb4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,9 @@ androidxJunit = "1.3.0" espresso = "3.7.0" detekt = "1.23.8" ktfmt = "0.27.0" +activity = "1.13.0" +cameraX = "1.6.2" +lifecycle = "2.9.4" [libraries] androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -19,6 +22,11 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From ef3ef6e40c656efef5e35edf603780ca3b0bf999 Mon Sep 17 00:00:00 2001 From: PSilver741 Date: Fri, 9 Oct 2026 01:46:54 -0700 Subject: [PATCH 3/6] minor changes to work on virtual machine --- app/build.gradle.kts | 12 ++++++++++++ app/src/main/AndroidManifest.xml | 1 + .../java/edu/sunlight/screentime/SessionPlanner.kt | 3 +-- gradle/gradle-daemon-jvm.properties | 10 ---------- gradle/libs.versions.toml | 2 +- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 09fb01b..59e4fde 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,3 +48,15 @@ detekt { buildUponDefaultConfig = true allRules = false } + +tasks.withType().configureEach { + jvmTarget = "17" + val javaVersion = System.getProperty("java.version") + enabled = !javaVersion.startsWith("25") +} + +tasks.withType().configureEach { + jvmTarget = "17" + val javaVersion = System.getProperty("java.version") + enabled = !javaVersion.startsWith("25") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dbe097d..5ce5fcd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + diff --git a/app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt b/app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt index b9b7b81..f8ffd13 100644 --- a/app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt +++ b/app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt @@ -1,13 +1,12 @@ package edu.sunlight.screentime import java.time.Instant -import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId internal object SessionPlanner { fun nextRelease(now: Instant, releaseTime: LocalTime, zone: ZoneId): Instant { - val today = LocalDate.ofInstant(now, zone) + 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() diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index fa4ed51..5a334ba 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,12 +1,2 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a304fb4..cbecaa4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.4.0" +agp = "9.4.1" kotlin = "2.3.21" composeBom = "2025.08.00" datastore = "1.2.1" From bbfbcbcadd226a8b6a768f287975dd1b54542d91 Mon Sep 17 00:00:00 2001 From: PSilver741 Date: Fri, 9 Oct 2026 17:04:46 -0700 Subject: [PATCH 4/6] Split code up to be in multiple files instead of one monolithic file. Also changed magic numbers to be named variables instead. --- .../sunlight/screentime/SunlightActivity.kt | 252 ++++-------------- .../edu/sunlight/screentime/SunlightCamera.kt | 73 +++++ .../edu/sunlight/screentime/SunlightScreen.kt | 138 ++++++++++ gradle/gradle-daemon-jvm.properties | 12 +- 4 files changed, 277 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/edu/sunlight/screentime/SunlightCamera.kt create mode 100644 app/src/main/java/edu/sunlight/screentime/SunlightScreen.kt diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt b/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt index f399dcd..a0171bd 100644 --- a/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt +++ b/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt @@ -8,31 +8,9 @@ import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -41,18 +19,17 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.preferencesDataStore import androidx.lifecycle.lifecycleScope -import java.nio.ByteBuffer import java.time.Duration import java.time.Instant import java.time.LocalTime import java.time.ZoneId -import java.time.format.DateTimeFormatter import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch private val android.content.Context.focusDataStore by preferencesDataStore(name = "focus_settings") private val releaseHourKey = intPreferencesKey("release_hour") private val releaseMinuteKey = intPreferencesKey("release_minute") +private const val FOCUS_SCREEN_BRIGHTNESS = 0.03f class SunlightActivity : ComponentActivity() { private var releaseTime by mutableStateOf(null) @@ -60,9 +37,8 @@ class SunlightActivity : ComponentActivity() { private var statusMessage by mutableStateOf("Choose the time to begin checking for sunlight.") private var sessionTarget: Instant? = null private var countdownEndsAt: Instant? = null - private var brightSince: Instant? = null - private var cameraProvider: ProcessCameraProvider? = null private var displayNow by mutableStateOf(Instant.now()) + private val sunlightCamera = SunlightCamera(this, ::sunlightDetected) private val cameraPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> @@ -78,8 +54,22 @@ class SunlightActivity : ComponentActivity() { val minute = preferences[releaseMinuteKey] if (hour != null && minute != null) releaseTime = LocalTime.of(hour, minute) } - setContent { SunlightScreen() } - tickClock() + setContent { + SunlightScreen( + state = + SunlightScreenState( + releaseTime = releaseTime, + phase = phase, + statusMessage = statusMessage, + displayNow = displayNow, + countdownEndsAt = countdownEndsAt, + ), + onChooseReleaseTime = ::chooseReleaseTime, + onBeginFocus = ::beginFocus, + onEndSession = ::endSession, + ) + } + tickClock { displayNow = it } } override fun onResume() { @@ -88,12 +78,12 @@ class SunlightActivity : ComponentActivity() { } override fun onDestroy() { - stopCamera() + sunlightCamera.stop() super.onDestroy() } private fun chooseReleaseTime() { - val current = releaseTime ?: LocalTime.of(7, 0) + val current = releaseTime ?: LocalTime.of(DEFAULT_RELEASE_HOUR, 0) TimePickerDialog( this, { _, hour, minute -> @@ -142,51 +132,23 @@ class SunlightActivity : ComponentActivity() { private fun updateSession() { val now = Instant.now() when (phase) { - FocusPhase.WAITING -> if (!now.isBefore(sessionTarget)) startCamera() + FocusPhase.WAITING -> + if (!now.isBefore(sessionTarget)) { + phase = FocusPhase.SEEKING_SUNLIGHT + statusMessage = "Move the phone into direct sunlight to finish the light check." + sunlightCamera.start() + } FocusPhase.FINAL_COUNTDOWN -> if (!now.isBefore(countdownEndsAt)) finishSession() else -> Unit } if (phase == FocusPhase.WAITING || phase == FocusPhase.FINAL_COUNTDOWN) { - window.decorView.postDelayed({ updateSession() }, 1_000) + window.decorView.postDelayed({ updateSession() }, UPDATE_INTERVAL_MILLIS) } } - private fun startCamera() { - phase = FocusPhase.SEEKING_SUNLIGHT - statusMessage = "Move the phone into direct sunlight to finish the light check." - val providerFuture = ProcessCameraProvider.getInstance(this) - providerFuture.addListener( - { - val provider = providerFuture.get() - cameraProvider = provider - val analysis = - ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - analysis.setAnalyzer(ContextCompat.getMainExecutor(this)) { frame -> - try { - if (meanLuma(frame.planes[0].buffer) >= LIGHT_THRESHOLD) { - val now = Instant.now() - if (brightSince == null) brightSince = now - if (Duration.between(brightSince, now) >= REQUIRED_BRIGHT_DURATION) - sunlightDetected() - } else { - brightSince = null - } - } finally { - frame.close() - } - } - provider.unbindAll() - provider.bindToLifecycle(this, CameraSelector.DEFAULT_FRONT_CAMERA, analysis) - }, - ContextCompat.getMainExecutor(this), - ) - } - private fun sunlightDetected() { if (phase != FocusPhase.SEEKING_SUNLIGHT) return - stopCamera() + sunlightCamera.stop() countdownEndsAt = Instant.now().plus(FINAL_FOCUS_DURATION) phase = FocusPhase.FINAL_COUNTDOWN statusMessage = "Sunlight detected. Your final 15-minute Focus countdown has begun." @@ -200,7 +162,7 @@ class SunlightActivity : ComponentActivity() { } private fun endSession() { - stopCamera() + sunlightCamera.stop() phase = FocusPhase.SETUP sessionTarget = null countdownEndsAt = null @@ -208,142 +170,38 @@ class SunlightActivity : ComponentActivity() { leaveFocusDisplay() } - private fun stopCamera() { - cameraProvider?.unbindAll() - cameraProvider = null - brightSince = null - } - - private fun enterFocusDisplay() { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - window.attributes = window.attributes.apply { screenBrightness = 0.03f } - hideSystemBars() - } - - private fun leaveFocusDisplay() { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - window.attributes = - window.attributes.apply { - screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE - } - WindowCompat.getInsetsController(window, window.decorView) - .show(WindowInsetsCompat.Type.systemBars()) + private companion object { + const val DEFAULT_RELEASE_HOUR = 7 + val FINAL_FOCUS_DURATION: Duration = Duration.ofMinutes(15) } +} - private fun meanLuma(buffer: ByteBuffer): Int { - val duplicate = buffer.duplicate() - var total = 0L - var count = 0 - while (duplicate.hasRemaining()) { - total += duplicate.get().toInt() and 0xff - count += 1 - } - return if (count == 0) 0 else (total / count).toInt() - } +private const val UPDATE_INTERVAL_MILLIS = 1_000L - private fun tickClock() { - displayNow = Instant.now() - window.decorView.postDelayed({ tickClock() }, 1_000) - } +private fun ComponentActivity.tickClock(onTick: (Instant) -> Unit) { + onTick(Instant.now()) + window.decorView.postDelayed({ tickClock(onTick) }, UPDATE_INTERVAL_MILLIS) +} - private fun hideSystemBars() { - WindowCompat.getInsetsController(window, window.decorView).apply { - systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - hide(WindowInsetsCompat.Type.systemBars()) - } - } +private fun ComponentActivity.enterFocusDisplay() { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.attributes = window.attributes.apply { screenBrightness = FOCUS_SCREEN_BRIGHTNESS } + hideSystemBars() +} - @androidx.compose.runtime.Composable - private fun SunlightScreen() { - val active = - phase == FocusPhase.WAITING || - phase == FocusPhase.SEEKING_SUNLIGHT || - phase == FocusPhase.FINAL_COUNTDOWN - val foreground = if (active) NightRed else Color.White - Column( - modifier = - Modifier.fillMaxSize() - .background(if (active) Color.Black else Color(0xff171717)) - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - BasicText( - currentClock(), - style = TextStyle(color = foreground, fontSize = 52.sp, fontWeight = FontWeight.Light), - ) - Spacer(Modifier.height(32.dp)) - BasicText( - statusMessage, - style = TextStyle(color = foreground, fontSize = 20.sp), - modifier = Modifier.semantics { contentDescription = statusMessage }, - ) - Spacer(Modifier.height(32.dp)) - if (!active) { - FocusAction("Choose release time", foreground, ::chooseReleaseTime) - Spacer(Modifier.height(16.dp)) - BasicText( - releaseTime?.let { "Check begins at ${displayTime(it)}" } ?: "No release time selected", - style = TextStyle(color = Color.LightGray, fontSize = 16.sp), - ) - Spacer(Modifier.height(24.dp)) - FocusAction("Start Focus", foreground, ::beginFocus) - } else { - if (phase == FocusPhase.FINAL_COUNTDOWN) { - BasicText( - "${remainingCountdown()} remaining", - style = TextStyle(color = foreground, fontSize = 18.sp), - ) - Spacer(Modifier.height(24.dp)) - } - BasicText( - "Hold to end session", - style = TextStyle(color = foreground, fontSize = 18.sp), - modifier = Modifier.combinedClickable(onClick = {}, onLongClick = ::endSession), - ) +private fun ComponentActivity.leaveFocusDisplay() { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.attributes = + window.attributes.apply { + screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE } - } - } - - @androidx.compose.runtime.Composable - private fun FocusAction(label: String, color: Color, action: () -> Unit) { - BasicText( - label, - style = TextStyle(color = color, fontSize = 20.sp), - modifier = - Modifier.fillMaxWidth() - .padding(16.dp) - .combinedClickable(onClick = action, onLongClick = action), - ) - } - - private fun currentClock(): String = - displayNow - .atZone(ZoneId.systemDefault()) - .toLocalTime() - .format(DateTimeFormatter.ofPattern("HH:mm")) - - private fun remainingCountdown(): String { - val seconds = - countdownEndsAt?.let { Duration.between(displayNow, it).seconds.coerceAtLeast(0) } ?: 0 - return String.format("%02d:%02d", seconds / 60, seconds % 60) - } - - private fun displayTime(time: LocalTime): String = - time.format(DateTimeFormatter.ofPattern("HH:mm")) - - private enum class FocusPhase { - SETUP, - WAITING, - SEEKING_SUNLIGHT, - FINAL_COUNTDOWN, - COMPLETE, - } + WindowCompat.getInsetsController(window, window.decorView) + .show(WindowInsetsCompat.Type.systemBars()) +} - private companion object { - const val LIGHT_THRESHOLD = 220 - val REQUIRED_BRIGHT_DURATION: Duration = Duration.ofSeconds(3) - val FINAL_FOCUS_DURATION: Duration = Duration.ofMinutes(15) - val NightRed = Color(0xffff5c5c) +private fun ComponentActivity.hideSystemBars() { + WindowCompat.getInsetsController(window, window.decorView).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.systemBars()) } } diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightCamera.kt b/app/src/main/java/edu/sunlight/screentime/SunlightCamera.kt new file mode 100644 index 0000000..2bf4648 --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SunlightCamera.kt @@ -0,0 +1,73 @@ +package edu.sunlight.screentime + +import androidx.activity.ComponentActivity +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import java.nio.ByteBuffer +import java.time.Duration +import java.time.Instant + +internal class SunlightCamera( + private val activity: ComponentActivity, + private val onSunlightDetected: () -> Unit, +) { + private var brightSince: Instant? = null + private var cameraProvider: ProcessCameraProvider? = null + + fun start() { + val providerFuture = ProcessCameraProvider.getInstance(activity) + providerFuture.addListener( + { + val provider = providerFuture.get() + cameraProvider = provider + val analysis = + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + analysis.setAnalyzer(ContextCompat.getMainExecutor(activity)) { frame -> + try { + if (meanLuma(frame.planes[0].buffer) >= LIGHT_THRESHOLD) { + val now = Instant.now() + if (brightSince == null) brightSince = now + if (Duration.between(brightSince, now) >= REQUIRED_BRIGHT_DURATION) { + onSunlightDetected() + } + } else { + brightSince = null + } + } finally { + frame.close() + } + } + provider.unbindAll() + provider.bindToLifecycle(activity, CameraSelector.DEFAULT_FRONT_CAMERA, analysis) + }, + ContextCompat.getMainExecutor(activity), + ) + } + + fun stop() { + cameraProvider?.unbindAll() + cameraProvider = null + brightSince = null + } + + private fun meanLuma(buffer: ByteBuffer): Int { + val duplicate = buffer.duplicate() + var total = 0L + var count = 0 + while (duplicate.hasRemaining()) { + total += duplicate.get().toInt() and UNSIGNED_BYTE_MASK + count += 1 + } + return if (count == 0) 0 else (total / count).toInt() + } + + private companion object { + const val LIGHT_THRESHOLD = 220 + const val UNSIGNED_BYTE_MASK = 0xff + val REQUIRED_BRIGHT_DURATION: Duration = Duration.ofSeconds(3) + } +} diff --git a/app/src/main/java/edu/sunlight/screentime/SunlightScreen.kt b/app/src/main/java/edu/sunlight/screentime/SunlightScreen.kt new file mode 100644 index 0000000..9aaa6d7 --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SunlightScreen.kt @@ -0,0 +1,138 @@ +package edu.sunlight.screentime + +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import java.time.Duration +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +private const val INACTIVE_BACKGROUND_ARGB = 0xff171717 +private const val NIGHT_RED_ARGB = 0xffff5c5c +private const val SECONDS_PER_MINUTE = 60 +private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm") + +internal data class SunlightScreenState( + val releaseTime: LocalTime?, + val phase: FocusPhase, + val statusMessage: String, + val displayNow: Instant, + val countdownEndsAt: Instant?, +) + +@Suppress("FunctionNaming") +@Composable +internal fun SunlightScreen( + state: SunlightScreenState, + onChooseReleaseTime: () -> Unit, + onBeginFocus: () -> Unit, + onEndSession: () -> Unit, +) { + val active = state.phase.isActive() + val foreground = if (active) Color(NIGHT_RED_ARGB) else Color.White + Column( + modifier = + Modifier.fillMaxSize() + .background(if (active) Color.Black else Color(INACTIVE_BACKGROUND_ARGB)) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + BasicText( + currentClock(state.displayNow), + style = TextStyle(color = foreground, fontSize = 52.sp, fontWeight = FontWeight.Light), + ) + Spacer(Modifier.height(32.dp)) + BasicText( + state.statusMessage, + style = TextStyle(color = foreground, fontSize = 20.sp), + modifier = Modifier.semantics { contentDescription = state.statusMessage }, + ) + Spacer(Modifier.height(32.dp)) + if (!active) { + FocusAction("Choose release time", foreground, onChooseReleaseTime) + Spacer(Modifier.height(16.dp)) + BasicText( + state.releaseTime?.let { "Check begins at ${displayTime(it)}" } + ?: "No release time selected", + style = TextStyle(color = Color.LightGray, fontSize = 16.sp), + ) + Spacer(Modifier.height(24.dp)) + FocusAction("Start Focus", foreground, onBeginFocus) + } else { + if (state.phase == FocusPhase.FINAL_COUNTDOWN) { + BasicText( + "${remainingCountdown(state.displayNow, state.countdownEndsAt)} remaining", + style = TextStyle(color = foreground, fontSize = 18.sp), + ) + Spacer(Modifier.height(24.dp)) + } + BasicText( + "Hold to end session", + style = TextStyle(color = foreground, fontSize = 18.sp), + modifier = Modifier.combinedClickable(onClick = {}, onLongClick = onEndSession), + ) + } + } +} + +@Suppress("FunctionNaming") +@Composable +private fun FocusAction(label: String, color: Color, action: () -> Unit) { + BasicText( + label, + style = TextStyle(color = color, fontSize = 20.sp), + modifier = + Modifier.fillMaxWidth() + .padding(16.dp) + .combinedClickable(onClick = action, onLongClick = action), + ) +} + +private fun FocusPhase.isActive(): Boolean = + this == FocusPhase.WAITING || + this == FocusPhase.SEEKING_SUNLIGHT || + this == FocusPhase.FINAL_COUNTDOWN + +private fun currentClock(now: Instant): String = + now.atZone(ZoneId.systemDefault()).toLocalTime().format(timeFormatter) + +private fun remainingCountdown(now: Instant, countdownEndsAt: Instant?): String { + val seconds = countdownEndsAt?.let { Duration.between(now, it).seconds.coerceAtLeast(0) } ?: 0 + return String.format( + Locale.ROOT, + "%02d:%02d", + seconds / SECONDS_PER_MINUTE, + seconds % SECONDS_PER_MINUTE, + ) +} + +internal fun displayTime(time: LocalTime): String = time.format(timeFormatter) + +internal enum class FocusPhase { + SETUP, + WAITING, + SEEKING_SUNLIGHT, + FINAL_COUNTDOWN, + COMPLETE, +} diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 5a334ba..6141f3e 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,2 +1,12 @@ #This file is generated by updateDaemonJvm -toolchainVersion=25 +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=17 \ No newline at end of file From b2136f8fffc508ed4db590af8bc17c3c57da8c4e Mon Sep 17 00:00:00 2001 From: Christopher Cox Date: Wed, 14 Oct 2026 12:27:14 -0700 Subject: [PATCH 5/6] plugin added to gradle settings --- settings.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index baee03d..efada51 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,6 +5,9 @@ pluginManagement { gradlePluginPortal() } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) From d68c53993bc26e3020fd17a1ef2efd9ff5c6032b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:29:45 +0000 Subject: [PATCH 6/6] fix: run CodeQL autobuild for Java/Kotlin analysis Co-authored-by: PSilver741 <240860506+PSilver741@users.noreply.github.com> --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 904e6df..75cda90 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -45,7 +45,7 @@ jobs: - uses: github/codeql-action/init@v4 with: languages: java-kotlin - build-mode: none + - uses: github/codeql-action/autobuild@v4 - uses: github/codeql-action/analyze@v4 ui-tests: