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..75cda90 --- /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 + - 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." 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..3a3e765 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ -# TechStartup-Template \ No newline at end of file +# 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..59e4fde --- /dev/null +++ b/app/build.gradle.kts @@ -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().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 new file mode 100644 index 0000000..5ce5fcd --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + 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..f8ffd13 --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SessionPlanner.kt @@ -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() + } +} 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..a0171bd --- /dev/null +++ b/app/src/main/java/edu/sunlight/screentime/SunlightActivity.kt @@ -0,0 +1,207 @@ +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.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +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.time.Duration +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +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) + 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 displayNow by mutableStateOf(Instant.now()) + private val sunlightCamera = SunlightCamera(this, ::sunlightDetected) + + 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( + state = + SunlightScreenState( + releaseTime = releaseTime, + phase = phase, + statusMessage = statusMessage, + displayNow = displayNow, + countdownEndsAt = countdownEndsAt, + ), + onChooseReleaseTime = ::chooseReleaseTime, + onBeginFocus = ::beginFocus, + onEndSession = ::endSession, + ) + } + tickClock { displayNow = it } + } + + override fun onResume() { + super.onResume() + if (phase != FocusPhase.SETUP && phase != FocusPhase.COMPLETE) hideSystemBars() + } + + override fun onDestroy() { + sunlightCamera.stop() + super.onDestroy() + } + + private fun chooseReleaseTime() { + val current = releaseTime ?: LocalTime.of(DEFAULT_RELEASE_HOUR, 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)) { + 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() }, UPDATE_INTERVAL_MILLIS) + } + } + + private fun sunlightDetected() { + if (phase != FocusPhase.SEEKING_SUNLIGHT) return + sunlightCamera.stop() + 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() { + sunlightCamera.stop() + phase = FocusPhase.SETUP + sessionTarget = null + countdownEndsAt = null + statusMessage = "Focus session ended." + leaveFocusDisplay() + } + + private companion object { + const val DEFAULT_RELEASE_HOUR = 7 + val FINAL_FOCUS_DURATION: Duration = Duration.ofMinutes(15) + } +} + +private const val UPDATE_INTERVAL_MILLIS = 1_000L + +private fun ComponentActivity.tickClock(onTick: (Instant) -> Unit) { + onTick(Instant.now()) + window.decorView.postDelayed({ tickClock(onTick) }, UPDATE_INTERVAL_MILLIS) +} + +private fun ComponentActivity.enterFocusDisplay() { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + window.attributes = window.attributes.apply { screenBrightness = FOCUS_SCREEN_BRIGHTNESS } + hideSystemBars() +} + +private fun ComponentActivity.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 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/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/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/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.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/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..6141f3e --- /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=17 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..cbecaa4 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,36 @@ +[versions] +agp = "9.4.1" +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" +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" } +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" } +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" } +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 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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..efada51 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "sunlight" +include(":app")