diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c31cadb..6ced5d9 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -13,18 +13,19 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: recursive - - uses: actions/setup-java@v4 + persist-credentials: false + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 with: distribution: temurin java-version: '21' - - uses: android-actions/setup-android@v3 + - uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 with: packages: platform-tools log-accepted-android-sdk-licenses: false - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 with: cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} - run: chmod +x gradlew @@ -34,7 +35,7 @@ jobs: # Generator-template tests run in :app:verifyNativeTemplates with Gradle's exact JAR. python3 -m unittest discover -s scripts/tests -p 'test_native_packaging.py' -v python3 scripts/check_native_packaging.py app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/release/app-release-unsigned.apk - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: android-reports @@ -44,6 +45,7 @@ jobs: app/build/outputs/apk/release app/build/outputs/bundle/release validation/native-packaging.json + app/build/generated/brand-fonts.json ui: runs-on: ubuntu-latest timeout-minutes: 60 @@ -54,18 +56,19 @@ jobs: device: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["compact","small-phone","common-phone","phone","large","small-tablet","tablet","wide-tablet","tablet-landscape","large-tablet"]' || '["phone","tablet"]') }} name: UI (${{ matrix.device }}) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: submodules: recursive - - uses: actions/setup-java@v4 + persist-credentials: false + - uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 with: distribution: temurin java-version: '21' - - uses: android-actions/setup-android@v3 + - uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 with: packages: platform-tools log-accepted-android-sdk-licenses: false - - uses: gradle/actions/setup-gradle@v4 + - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 with: cache-read-only: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} - name: Enable KVM @@ -74,14 +77,15 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - run: chmod +x gradlew - - uses: reactivecircus/android-emulator-runner@v2 + - name: Run native UI journeys + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2 with: api-level: 35 arch: x86_64 target: google_apis profile: ${{ contains(matrix.device, 'tablet') && 'pixel_tablet' || 'pixel_6' }} script: bash scripts/device-matrix.sh ${{ matrix.device }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 if: always() with: name: android-ui-${{ matrix.device }} diff --git a/.gitignore b/.gitignore index 990a4fa..610d0d4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,14 @@ google-services.json /validation/ __pycache__/ *.pyc + +# Local deployment credentials; public branding and synthetic test fixtures stay tracked. +.env +.env.* +!.env.example +*.p12 +*.pfx +*.pem +*.key +keystore.properties +signing.properties diff --git a/AGENTS.md b/AGENTS.md index e4c1b38..44405ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,16 +19,16 @@ ## Branch delivery workflow -The owner merged the A0 foundation and requested this workflow on 2026-10-16: +The owner's latest delivery instruction, updated 2026-10-18, authorizes the existing PR #4 delivery workflow: -- Start ongoing work from `dev`, commit there, and push to `origin/dev`. -- Finish the phase's implementation and validation before opening its final `dev` → `main` pull request. -- Keep `main` for reviewed merges. Preserve the owner's configured commit identity and the required Codex co-author trailer. +- Consolidate ongoing mobile work on `dev`, retain phase commits, and push tested progress to `origin/dev`; do not create other pull requests or feature branches. +- Complete exact-source local/CI checks, the required full device matrix and actual screenshot review, then merge only the reviewed head of existing [PR #4](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/4) into `main`. Keep `dev` and preserve phase commits. +- Preserve the owner's configured commit identity, the required Codex co-author trailer and all immutable inputs. A failed or skipped required gate cannot establish acceptance. ## Owner execution extension — 2026-10-16 -The owner's latest complete-build instruction extends the original PRD: implement remaining phases on `dev`, commit and push tested progress there, and prepare the final `dev` → `main` PR after the batch. Intermediate merge gates are superseded; green CI remains mandatory before proceeding between phases. Phone AND tablet layouts are now in scope, including 320/360dp phones, common/large phones, 600/840dp tablets, portrait/landscape, light/dark and 2× font scale. Keep `docs/PRD.md` byte-identical to its owner source; record extensions here and in `docs/IMPLEMENTATION-PLAN.md`. Preserve Shaan's GitHub identity plus the Codex co-author trailer. Use the local Docker API for integration, contract/generated models only, no backend implementation edits or provider/attestation bypasses. Document precise backend-request issues for unavailable native capabilities; continue independent supported work. +The owner's complete-build extension adds remaining phases on `dev` with tested progress committed and pushed there. The latest delivery instruction authorizes only existing PR #4: consolidate and validate `dev`, then merge its reviewed head into `main`, retaining `dev` and phase commits. Do not create other pull requests or feature branches. Intermediate merge waits are superseded; green CI remains mandatory for phase acceptance. Phone AND tablet layouts are now in scope, including 320/360dp phones, common/large phones, 600/840dp tablets, portrait/landscape, light/dark and 2× font scale. Keep `docs/PRD.md` byte-identical to its owner source; record extensions here and in `docs/IMPLEMENTATION-PLAN.md`. Preserve Shaan's GitHub identity plus the Codex co-author trailer. Use the local Docker API for integration, contract/generated models only, no backend implementation edits or provider/attestation bypasses. Document precise backend-request issues for unavailable native capabilities; continue independent supported work. Owner backend deferment: complete and test the full native flow using an explicit DEBUG-only, isolated in-memory demo. The backend will be supplied later. Never infer live success or invent production routes/models; missing native contract/provider configuration stays visibly unavailable outside demo. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..429a758 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing and maintenance + +This is a native Kotlin/Jetpack Compose application. Read [AGENTS.md](AGENTS.md) +and the unchanged [PRD](docs/PRD.md) before changing behavior. The owner currently +authorizes the existing [PR #4](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/4) +for final delivery: work on `dev`, preserve tested phase commits and `dev`, and +merge only the reviewed head after its required gates pass. Do not create other +pull requests or feature branches for this batch. + +## Local checks + +Use JDK 21, Python 3 and Android SDK platform 36/build tools. Clone recursively, +switch to `dev`, and retain the pinned Design submodule. Keep SDK paths in ignored +`local.properties` or `ANDROID_HOME`; never commit local credentials. + +```sh +git submodule update --init --recursive +./gradlew lint test assembleDebug assembleRelease bundleRelease assembleDebugAndroidTest +python3 -m unittest discover -s scripts/tests -p 'test_native_packaging.py' -v +python3 scripts/check_native_packaging.py app/build/outputs/apk/debug/app-debug.apk app/build/outputs/apk/release/app-release-unsigned.apk +``` + +Use `gradlew.bat` and `python` on Windows. Run `connectedDebugAndroidTest` with a +ready emulator/device for native journeys. The manually dispatched Android +workflow runs every case across the ten profiles in +[release readiness](docs/RELEASE-READINESS.md). Do not skip failing cases or infer +actual layout quality from compilation or a screenshot listing. + +## Change boundaries + +- Use only the generated API client and authoritative schema. Do not invent + endpoints, models, production successes or backend behavior. +- Generated API, theme, typography and copy output belongs under build directories; + change the reviewed inputs/generators instead. Keep `docs/PRD.md`, + `api/openapi.json` and the Design gitlink unchanged unless explicitly authorized. +- Retain source-generation, unit/transport, lint, native packaging and relevant + instrumented checks. Review original full-OS captures for affected phone/tablet, + theme, orientation and text-scale states. Keep secrets and recovery codes out + of logs, issues and evidence. +- Preserve the configured Shaan commit identity and required Codex attribution + for Codex-authored commits. Record concrete behavior, tests and remaining owner + gates; do not claim live backend/provider or store acceptance from demo tests. +- Dependency/toolchain changes require a separate reviewed phase and a resolved + graph/provenance comparison; see [dependency maintenance](docs/DEPENDENCIES.md). + Do not automatically update SDKs, generate update PRs or trust new checksum + metadata without review. + +The backend is deferred. Debug's labelled in-memory demo is isolated; Release +does not acquire production authority from it. Signing, legal/privacy inputs, +physical-device/provider verification and store distribution remain owner gates. + +Report vulnerabilities through the [security policy](SECURITY.md), not a public +issue containing credentials or private account data. diff --git a/README.md b/README.md index 3fa9437..f5caa29 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,78 @@ -# Space Works Android +

+
+ + + Space Works + +

+

-Native Kotlin/Compose app for Android 8+ (API 26), targeting API 36, for phones and tablets. [The PRD](docs/PRD.md) defines the flows; the owner's extensions in [AGENTS.md](AGENTS.md) add tablets, shared onboarding/auth UX and completion of the remaining phases on `dev`. [CHANGELOG.md](docs/CHANGELOG.md) records actual phase implementation, tests and parity. Only recorded passing phases count as complete. +# Space Works for Android -## Development workflow +Find tools. Book a space. Keep your making moving. -Use `dev` for ongoing changes and push commits to `origin/dev`. After implementation and CI validation finish, open the final pull request from `dev` to `main`. The A0 foundation is already merged into `main`. +A native **Kotlin + Jetpack Compose** app for makerspace members and staff, with Material 3 controls, adaptive phone/tablet navigation, supplied Gilroy typography, light/dark themes and accessible text scaling. Runs on **Android 8+ (API 26)** and targets **API 36**. -## Build +Also available as a native [iOS app](https://github.com/SpaceWorks-HQ/SpaceWorks-iOS). -Install Java 21, Python 3 and Android SDK 36. Clone with `--recurse-submodules`, set `ANDROID_HOME` or untracked `local.properties`, then run: +The backend will be supplied later. Debug includes an explicitly labelled, isolated demo; live provider, payment, push and store acceptance remain separate deployment gates. + +## Screenshots + +Historical gallery: original full-OS emulator captures from tested app commit [`11cc205`](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/commit/11cc205c39294baeb3116038a5eb525519813eab). The gallery includes labelled Debug sample flows. Open any image for its full size; [capture provenance](docs/images/PROVENANCE.json) records the source, artifact and original-byte hashes. + +| Introduction | Sign in · dark | Tablet Home | +| --- | --- | --- | +| [First-use introduction with the supplied Space Works symbol](docs/images/screenshots/introduction.png) | [Dark native sign-in with password visibility and account actions](docs/images/screenshots/sign-in-dark.png) | [Tablet navigation rail, Home guidance and quick actions](docs/images/screenshots/tablet-home.png) | +| Request receipt | Account devices | Staff confirmation | +| [Demo request received with pending status](docs/images/screenshots/request-receipt.png) | [Global account device controls without a makerspace membership](docs/images/screenshots/account-devices.png) | [Native confirmation before accepting demo staff request quantities](docs/images/screenshots/staff-confirmation.png) | + +## What’s inside + +- Inventory search, equipment requests, bookings, events, membership cards and inbox updates. +- Account/profile settings, security and device sessions, membership actions and export journeys. +- Authorized staff issue, return and check-in flows with explicit review and evidence steps. +- Replayable introduction, scoped Home tips and **Profile → App guide**. +- Native document previews, notification routing, system-aware motion and owned haptic feedback. + +Production uses the generated [OpenAPI client](api/README.md). Features without the required native contract or deployment configuration remain honestly unavailable; demo success never establishes a live session, charge or deletion. See [backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/issues/3) and the [release handoff](docs/RELEASE-READINESS.md). + +## Build and run + +Install **Java 21**, **Python 3**, Android Studio and **Android SDK 36**. Clone with submodules and configure `ANDROID_HOME` or an untracked `local.properties`: ```sh +git clone --recurse-submodules https://github.com/SpaceWorks-HQ/SpaceWorks-Android.git +cd SpaceWorks-Android +git switch dev git submodule update --init --recursive ./gradlew lint test assembleDebug assembleRelease bundleRelease -./gradlew connectedDebugAndroidTest ``` -Use `gradlew.bat` on Windows. Instrumented tests need an emulator/device. CI builds both variants, runs lint/unit tests, validates native-library and APK 16 KB alignment, and exercises phone/tablet journeys. Manually dispatch the workflow for the full ten-profile matrix: compact through large phones, 600/800/840/1024dp tablets, and tablet landscape. The tests record the app’s actual logical width, appearance, text scale and window orientation. Inspect actual display sizes, configuration and full-screen screenshots; the matrix does not represent every Android device. See [RELEASE-READINESS.md](docs/RELEASE-READINESS.md) for evidence and the separate 16 KB runtime-device requirement. +Use `gradlew.bat` on Windows. Open the repository in Android Studio and run `app`; choose **Explore demo** in Debug to try the member and staff flows without a backend. Release omits demo entry points and implementations. Debug APKs are installable for development; CI Release outputs are unsigned and Play distribution requires owner signing/provisioning. + +For instrumented journeys, start an emulator/device and run `./gradlew connectedDebugAndroidTest`. Gradle generates branding, motion, copy and API sources from the repository inputs; generated output stays outside Git. + +## Verified app baseline + +Current evidence is bound to tested source commit [`3a400c7`](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/commit/3a400c7bcf7c16164393ea493983a4bd173a710b), including the C1 repository-hardening inputs. Later Markdown-only handoff commits are separate from that tested source. The gallery above retains its historical `11cc205` captures and unchanged provenance. + +| Gate | Accepted result | +| --- | --- | +| [Canonical CI](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/actions/runs/39105105315) | Build, lint, Debug/Release validation and phone/tablet jobs passed | +| [Full device matrix](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/actions/runs/39105109733) | 59 UI tests × 10 profiles = **590**, zero failures, errors or skips | +| Unit tests | **538 Debug + 506 Release = 1,044**, zero failures or skips | +| Native/UI proof | Supplied font resolution, haptic dispatch/settings, measured profiles, real keyboard insets and configuration-backed 3.2× PDF dialog layout | +| Packaging and review | 16 KB APK/ELF alignment and exact bundled Unbounded OFL checks; **210 original full-OS screenshots** manually reviewed | -Open this repository in Android Studio and run `app`. Choose a server on launch or lock one HTTPS origin in `branding/brand.json`. Debug builds allow documented local Docker origins and an explicitly labelled, isolated in-memory demo. Demo authentication, notifications and checkout previews do not establish a live session, push delivery or payment. Release builds omit demo implementations and entry points. [LOCAL-DOCKER-VALIDATION.md](docs/LOCAL-DOCKER-VALIDATION.md) separates local API evidence from fixtures. +The matrix covers compact through large phones, 600/800/840/1024dp tablets and tablet landscape, across light/dark and large text. Emulators do not establish tactile quality, live provider/backend operation or the separate physical 16 KB runtime-device gate. -The backend and owner provisioning are deferred. Unsupported native contracts remain unavailable; see [backend dependencies](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/issues/3). Real integrity, providers, push, checkout, account deletion and store distribution require the deployment inputs and verification in the release guide. The Debug APK can be installed for development and the isolated preview. Play distribution uses an owner-signed release build; CI release outputs are unsigned. +## Configuration and documentation -Gradle regenerates native semantic colors and branding from the pinned Design submodule, motion from local `tokens/mobile-motion.json`, and the API client from the [contract](api/README.md). Generated sources stay out of Git. Never replace the pinned schema with a local server's schema or hand-write API payload models. See [WHITE-LABEL.md](docs/WHITE-LABEL.md) for configuration. Space Works uses static logo masters, native system text and licensed Unbounded accent text. Gilroy is not embedded without app-embedding permission. Never commit signing material, provider secrets or test credentials. +Set deployment branding and an optional locked HTTPS origin in [`branding/brand.json`](branding/brand.json). Keep provider keys, signing files and credentials untracked. The supplied logo masters and font provenance are retained; use the [white-label guide](docs/WHITE-LABEL.md) for supported replacements. -## Use and review the app +[User guide](docs/USER-GUIDE.md) · [Release readiness](docs/RELEASE-READINESS.md) · [Store listing](docs/STORE-LISTING.md) · [Local API evidence](docs/LOCAL-DOCKER-VALIDATION.md) · [Changelog](docs/CHANGELOG.md) · [PRD](docs/PRD.md) -In Debug, choose **Explore demo** to use isolated member and staff journeys without a backend. -Open **Profile → App guide** for task instructions, introduction replay and helpful-tip reset. -The first-use Home tip is dismissible and scoped to the actual account and makerspace. Staff -guidance appears only in an authorized staff context. See [USER-GUIDE.md](docs/USER-GUIDE.md). -Prepared store copy, screenshot selection and private reviewer-access notes are in -[STORE-LISTING.md](docs/STORE-LISTING.md). +[Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md) · [Dependency provenance](docs/DEPENDENCIES.md) -Build and device evidence is linked from the [dev → main pull request](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/2). -Keep simulator/demo checks separate from live provider and physical-device acceptance; the -[release handoff](docs/RELEASE-READINESS.md) records those deployment inputs. +Ongoing work stays on `dev`, with tested phase commits pushed there. The owner authorized the existing [PR #4](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/4) for delivery to `main`: merge only its reviewed head after exact-source CI and visual gates pass. Preserve `dev` and phase commits; do not create other pull requests or feature branches. Keep the PRD, API contract and pinned Design inputs unchanged. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2664d22 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,46 @@ +# Security policy + +## Reporting + +Use GitHub's private **Report a vulnerability** entry in this repository's +[Security tab](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/security). +The [private reporting form](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/security/advisories/new) +is usable only when the owner has enabled private vulnerability reporting. If it +is unavailable, open an issue asking only for a private reporting channel; do not +include the vulnerability details or sensitive evidence publicly. No reporting +email, response deadline or bounty is asserted here. + +Include the exact commit/build, Android version/device, a minimal reproduction, +expected versus actual behavior and the affected trust boundary. Redact tokens, +passwords, verification/recovery codes, private document URLs, account data and +signing/provider configuration. Do not access other people's accounts or run +destructive checks against a live makerspace. Use local fixtures or an explicitly +authorized test deployment. Never commit a discovered secret; notify the owner +privately so it can be revoked at its actual issuer. + +## Scope and support + +`dev` contains the current reviewed work; `main` receives the owner-authorized +delivery. There is no declared production support window or published security +release SLA. Report issues against the exact source, including the generated +client, native storage, request ownership, links, document handling and build +supply chain. A dependency alert or package checksum check is not a complete +security assessment. + +The backend, production attestation/provider setup, signing and store deployment +are separate owner gates. Labelled Debug fixtures do not establish a live session, +settled charge, deletion or successful provider setup. Preserve honest unavailable +states when the generated native contract or deployment configuration is absent. + +## Maintainer handling + +Keep reports and reproductions private until the owner agrees on disclosure. +Confirm affected versions and ownership boundaries, add meaningful regression +coverage and validate the exact corrected source before delivery. Do not disable +TLS, attestation, authorization or secure-window protections to make a test pass. + +Use the existing owner-authorized delivery workflow and preserve phase commits. +Store credentials, signing material and incident artifacts outside Git. Review +the current GitHub protection/reporting/scanning settings and the +[release checklist](docs/RELEASE-READINESS.md); this policy does not enable those +settings by itself. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3da4a6e..a7892e6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,12 +34,15 @@ android { lint { abortOnError = true } } val prepareBrand by tasks.registering(Exec::class) { - dependsOn("testBrandLinks") + dependsOn("testBrandLinks","testBrandFonts") inputs.files(brand.filterKeys {it.toString().startsWith("logo_")}.values.map {rootProject.file(it as String)}) inputs.file(rootProject.file("tokens/mobile-motion.json")) inputs.file(rootProject.file("scripts/brand_links.py")) + inputs.file(rootProject.file("scripts/brand_fonts.py")) + inputs.files(brand.filterKeys {it.toString().startsWith("font_")}.values.map {rootProject.file(it as String)}) inputs.files(brandFile, rootProject.file(brand["theme_tokens"] as String), rootProject.file(brand["app_icon"] as String), rootProject.file("scripts/prepare.py")) outputs.dirs(layout.buildDirectory.dir("generated/source/brand"),layout.buildDirectory.dir("generated/res/brand")) + outputs.file(layout.buildDirectory.file("generated/brand-fonts.json")) commandLine(if (System.getProperty("os.name").startsWith("Windows")) "python" else "python3", rootProject.file("scripts/prepare.py")) } val testContractSelection by tasks.registering(Exec::class) { @@ -82,6 +85,12 @@ val testMobileCopyGenerator by tasks.registering(Exec::class) { inputs.files(rootProject.file("scripts/generate_mobile_copy.py"),rootProject.file("scripts/tests/test_mobile_copy.py")) commandLine(if (System.getProperty("os.name").startsWith("Windows")) "python" else "python3","-m","unittest","discover","-s","scripts/tests","-p","test_mobile_copy.py","-v") } +val testBrandFonts by tasks.registering(Exec::class) { + workingDir(rootProject.projectDir) + inputs.files(rootProject.file("scripts/brand_fonts.py"),rootProject.file("scripts/tests/test_brand_fonts.py")) + inputs.files(brand.filterKeys {it.toString().startsWith("font_")}.values.map {rootProject.file(it as String)}) + commandLine(if (System.getProperty("os.name").startsWith("Windows")) "python" else "python3","-m","unittest","discover","-s","scripts/tests","-p","test_brand_fonts.py","-v") +} val testBrandLinks by tasks.registering(Exec::class) { workingDir(rootProject.projectDir) inputs.files(rootProject.file("scripts/brand_links.py"),rootProject.file("scripts/prepare.py"),rootProject.file("scripts/tests/test_brand_links.py")) diff --git a/app/src/androidTest/java/org/spaceworks/app/AccountManagementJourneyTest.kt b/app/src/androidTest/java/org/spaceworks/app/AccountManagementJourneyTest.kt index 133676f..84aebd5 100644 --- a/app/src/androidTest/java/org/spaceworks/app/AccountManagementJourneyTest.kt +++ b/app/src/androidTest/java/org/spaceworks/app/AccountManagementJourneyTest.kt @@ -26,7 +26,7 @@ import org.spaceworks.app.theme.LightScheme @Composable internal fun A7ComponentHost(largeText:Boolean=false,content:@Composable ()->Unit) { val density=LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density,if(largeText)3.2f else density.fontScale)) { - MaterialTheme(colorScheme=if(DeviceProfile.dark)org.spaceworks.app.theme.DarkScheme else LightScheme,shapes=Shapes(small=RoundedCornerShape(10.dp),medium=RoundedCornerShape(16.dp))) { + org.spaceworks.app.theme.SpaceWorksTheme(DeviceProfile.dark) { Surface(Modifier.fillMaxSize()){Box(Modifier.fillMaxSize().safeDrawingPadding()){content()}} } } diff --git a/app/src/androidTest/java/org/spaceworks/app/BrandTypographyTest.kt b/app/src/androidTest/java/org/spaceworks/app/BrandTypographyTest.kt new file mode 100644 index 0000000..d5e22d1 --- /dev/null +++ b/app/src/androidTest/java/org/spaceworks/app/BrandTypographyTest.kt @@ -0,0 +1,50 @@ +package org.spaceworks.app + +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.ui.text.font.FontSynthesis +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.core.content.res.ResourcesCompat +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* +import org.junit.Test +import org.spaceworks.app.theme.BrandFontFamily + +/** Real Android resource + Compose registration proof, using the supplied fonts' distinct M advances. */ +class BrandTypographyTest { + @Test fun suppliedWeightsResolveToTheirActualNativeGlyphMetricsAtMultipleScales() { + val context=InstrumentationRegistry.getInstrumentation().targetContext + val resolver=createFontFamilyResolver(context) + val xmlFamily=requireNotNull(ResourcesCompat.getFont(context,R.font.gilroy)) + val faces=listOf( + Triple(R.font.gilroy_regular,FontWeight.Normal,.800f), + Triple(R.font.gilroy_medium,FontWeight.Medium,.805f), + Triple(R.font.gilroy_semibold,FontWeight.SemiBold,.810f), + Triple(R.font.gilroy_bold,FontWeight.Bold,.815f) + ) + // measureText deliberately ceils widths; compare fractional native runs to supplied hmtx. + faces.forEach {(resource,weight,advance) -> + val native=requireNotNull(ResourcesCompat.getFont(context,resource)) + val resolved=resolver.resolve(BrandFontFamily,weight,fontSynthesis=FontSynthesis.None).value as Typeface + listOf(16f,32f,51.2f).forEach {size -> + val paint=Paint().apply {textSize=size;typeface=native;isSubpixelText=true;isLinearText=true} + assertEquals("Supplied face $weight",advance*size,paint.getRunAdvance("M",0,1,0,1,false,1),.03f) + paint.typeface=resolved + assertEquals("Compose must select supplied weight $weight",advance*size,paint.getRunAdvance("M",0,1,0,1,false,1),.03f) + if(android.os.Build.VERSION.SDK_INT>=28) { + paint.typeface=Typeface.create(xmlFamily,weight.weight,false) + assertEquals("SDK XML family must resolve the actual weight $weight",advance*size,paint.getRunAdvance("M",0,1,0,1,false,1),.03f) + } + } + } + if(android.os.Build.VERSION.SDK_INT>=31) { + val configuration=android.content.res.Configuration(context.resources.configuration).apply {fontWeightAdjustment=300} + val boldContext=context.createConfigurationContext(configuration) + val boldResolver=createFontFamilyResolver(boldContext) + val bold=boldResolver.resolve(BrandFontFamily,FontWeight.Normal,fontSynthesis=FontSynthesis.None).value as Typeface + val paint=Paint().apply {textSize=100f;typeface=bold;isSubpixelText=true;isLinearText=true} + assertEquals("System bold-text adjustment selects the supplied Bold face",81.5f,paint.getRunAdvance("M",0,1,0,1,false,1),.03f) + } + } +} diff --git a/app/src/androidTest/java/org/spaceworks/app/CheckoutRecoveryJourneyTest.kt b/app/src/androidTest/java/org/spaceworks/app/CheckoutRecoveryJourneyTest.kt index 6c042d0..fe73b40 100644 --- a/app/src/androidTest/java/org/spaceworks/app/CheckoutRecoveryJourneyTest.kt +++ b/app/src/androidTest/java/org/spaceworks/app/CheckoutRecoveryJourneyTest.kt @@ -21,7 +21,7 @@ class CheckoutRecoveryJourneyTest { compose.setContent { val density=LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density,3.2f)) { - MaterialTheme(colorScheme=if(DeviceProfile.dark)DarkScheme else LightScheme) { + org.spaceworks.app.theme.SpaceWorksTheme(DeviceProfile.dark) { CheckoutRecoveryScreen {refreshes++} } } diff --git a/app/src/androidTest/java/org/spaceworks/app/NativeFeedbackTest.kt b/app/src/androidTest/java/org/spaceworks/app/NativeFeedbackTest.kt new file mode 100644 index 0000000..e85452d --- /dev/null +++ b/app/src/androidTest/java/org/spaceworks/app/NativeFeedbackTest.kt @@ -0,0 +1,48 @@ +package org.spaceworks.app + +import android.content.Context +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.lifecycle.Lifecycle +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.spaceworks.app.platform.FeedbackKind +import org.spaceworks.app.platform.NativeActionFeedback + +/** Proves native dispatch/settings gates, not the physical tactile quality of emulator hardware. */ +class NativeFeedbackTest { + @get:Rule val compose=createAndroidComposeRule() + private class FeedbackView(context:Context):View(context) { + var calls=0 + override fun performHapticFeedback(feedbackConstant:Int):Boolean {calls++;return true} + } + @Test fun attachedNativeViewDispatchesFreshOutcomeOnlyAndRespectsViewDisablement() { + lateinit var view:FeedbackView + compose.setContent {AndroidView(factory={FeedbackView(it).also {created->view=created}},modifier=Modifier.size(48.dp))} + compose.runOnIdle { + assertTrue(view.isAttachedToWindow) + var owner="account-a" + val feedback=NativeActionFeedback(view,{owner},{compose.activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)}) + assertEquals(0,view.calls) + val first=feedback.begin() + feedback.finish(first,FeedbackKind.CONFIRM) + feedback.finish(first,FeedbackKind.CONFIRM) + assertEquals(1,view.calls) + view.isHapticFeedbackEnabled=false + feedback.finish(feedback.begin(),FeedbackKind.REJECT) + assertEquals(1,view.calls) + view.isHapticFeedbackEnabled=true + val stale=feedback.begin();owner="account-b" + feedback.finish(stale,FeedbackKind.CONFIRM) + assertEquals(1,view.calls) + feedback.finish(feedback.begin(),FeedbackKind.SELECTION) + assertEquals(2,view.calls) + } + } +} diff --git a/app/src/androidTest/java/org/spaceworks/app/PaymentsJourneyTest.kt b/app/src/androidTest/java/org/spaceworks/app/PaymentsJourneyTest.kt index 1ecbb74..9448d87 100644 --- a/app/src/androidTest/java/org/spaceworks/app/PaymentsJourneyTest.kt +++ b/app/src/androidTest/java/org/spaceworks/app/PaymentsJourneyTest.kt @@ -5,9 +5,15 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Density @@ -27,18 +33,38 @@ class PaymentsJourneyTest { @get:Rule val compose=createComposeRule() private val journey by lazy {NativeJourney(compose,"a5")} private val launches=AtomicInteger() - private fun start(dark:Boolean=DeviceProfile.dark,largeText:Boolean=false):PaymentsStateHolder { + private var documentContextScale:Float?=null + private val documentTextProofs=org.json.JSONArray() + @OptIn(ExperimentalTestApi::class) + private fun start(dark:Boolean=DeviceProfile.dark,largeText:Boolean=false,configurationBackedDocument:Boolean=false):PaymentsStateHolder { val context=InstrumentationRegistry.getInstrumentation().targetContext val holder=PaymentsStateHolder(DemoPaymentsGateway(),CachedPaymentFiles(File(context.cacheDir,"native-documents")),pause={}).apply{activate(MemberScope(7,"demo-makerspace"))} compose.setContent { - val density=LocalDensity.current - CompositionLocalProvider(LocalDensity provides Density(density.density,if(largeText)3.2f else density.fontScale)){ - MaterialTheme(colorScheme=if(dark)DarkScheme else LightScheme,shapes=Shapes(small=RoundedCornerShape(10.dp),medium=RoundedCornerShape(16.dp))){ + @Composable fun paymentContent() { + org.spaceworks.app.theme.SpaceWorksTheme(dark){ Surface(Modifier.fillMaxSize()){Box(Modifier.fillMaxSize().safeDrawingPadding()){ PaymentsScreen(holder,true,{},launchStripe={launches.incrementAndGet();PaymentLaunchResult.DEMO_BLOCKED}) }} } } + val density=LocalDensity.current + if(configurationBackedDocument) { + // Dialog creates a new native owner from LocalView.context; a density-only + // override on the parent composition cannot configure that window. + DeviceConfigurationOverride(DeviceConfigurationOverride.FontScale(3.2f)) { + AndroidView(modifier=Modifier.fillMaxSize(),factory={scopedContext -> + documentContextScale=scopedContext.resources.configuration.fontScale + ComposeView(scopedContext).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent {paymentContent()} + } + },onRelease={it.disposeComposition()}) + } + } else { + CompositionLocalProvider(LocalDensity provides Density(density.density,if(largeText)3.2f else density.fontScale)){ + paymentContent() + } + } } journey.systemBars(dark);waitText("Sample equipment deposit");return holder } @@ -69,11 +95,64 @@ class PaymentsJourneyTest { assertNull(session.payments!!.state.value.selectedId);assertEquals(0,launches.get());shot("a5-app-maker-payment-isolation") } + private fun documentAction(label:String) { + val action=journey.visible(compose.onNodeWithText(label)).assertIsEnabled() + assertEquals("Native bridge context must carry the configuration-backed scale",3.2f,requireNotNull(documentContextScale),.001f) + val textNode=compose.onNodeWithText(label,useUnmergedTree=true) + val ownerScale=textNode.fetchSemanticsNode().layoutInfo.density.fontScale + assertEquals("The actual dialog text owner must retain that scale",3.2f,ownerScale,.001f) + val layouts=mutableListOf() + textNode.performSemanticsAction(SemanticsActions.GetTextLayoutResult){it(layouts)} + val layout=layouts.single();val input=layout.layoutInput + assertEquals("Actual dialog text layout must use the overridden configuration",3.2f,input.density.fontScale,.001f) + assertTrue(input.style.fontSize.isSp && input.style.fontSize.value>0) + val unscaledPx=input.style.fontSize.value*input.density.density + val scaledPx=with(input.density){input.style.fontSize.toPx()} + val characterHeight=layout.getBoundingBox(0).height + val lineHeight=layout.getLineBottom(0)-layout.getLineTop(0) + assertTrue("Actual dialog character boxes and line metrics must be enlarged",scaledPx>unscaledPx*1.5f && characterHeight>unscaledPx*1.5f && lineHeight>unscaledPx*1.5f) + val lineEnds=(0 until layout.lineCount).map{layout.getLineEnd(it,visibleEnd=true)} + documentTextProofs.put(org.json.JSONObject().put("action",label).put("context_font_scale",documentContextScale) + .put("owner_font_scale",ownerScale).put("dialog_font_scale",input.density.fontScale) + .put("font_size_sp",input.style.fontSize.value).put("unscaled_font_pixels",unscaledPx) + .put("scaled_font_pixels",scaledPx).put("character_box_height_pixels",characterHeight).put("line_height_pixels",lineHeight) + .put("line_end_offsets",org.json.JSONArray(lineEnds))) + val output=InstrumentationRegistry.getInstrumentation().targetContext.getExternalFilesDir("validation")!! + output.mkdirs();File(output,"a5-native-pdf-action-scale.json").writeText(documentTextProofs.toString(2),Charsets.UTF_8) + lineEnds.dropLast(1).forEach{end->assertTrue("$label must wrap between complete words",end>=label.length || label[end].isWhitespace() || (end>0 && label[end-1].isWhitespace()))} + val viewport=action.onAncestors().filter(hasScrollAction()).onFirst().fetchSemanticsNode().boundsInRoot + val actual=action.fetchSemanticsNode().boundsInRoot + assertTrue("Full $label action must fit the actual document scroll viewport",actual.width>0 && actual.height>0 && + actual.left>=viewport.left && actual.right<=viewport.right && actual.top>=viewport.top && actual.bottom<=viewport.bottom) + } + private fun demoAction(label:String) { + val action=journey.visible(compose.onNodeWithText(label)).assertIsEnabled() + val viewport=compose.onNodeWithTag("demo-payment-outcome-scroll").fetchSemanticsNode().boundsInRoot + val actual=action.fetchSemanticsNode().boundsInRoot + assertTrue("Full $label action must fit the actual demo scroll viewport",viewport.width>0 && viewport.height>0 && + actual.width>0 && actual.height>0 && actual.left>=viewport.left && actual.right<=viewport.right && + actual.top>=viewport.top && actual.bottom<=viewport.bottom) + } @Test fun serverConfirmedPreviewCheckoutOpensNativeReceiptWithoutAnySDKCharge(){ - val holder=start();openDeposit();shot("a5-payment-review");click("Continue to payment");waitText("Preview checkout");click("Server confirms payment");waitText("Paid online");shot("a5-server-confirmed-payment") + val holder=start(largeText=true,configurationBackedDocument=true);openDeposit();shot("a5-payment-review");click("Continue to payment");waitText("Preview checkout") + listOf("Server confirms payment","Checkout cancelled","Checkout failed","Confirmation delayed","Offline","Payment unavailable","Keep reviewing").forEach(::demoAction) + demoAction("Server confirms payment");shot("a5-demo-checkout-confirm-action");click("Server confirms payment");waitText("Paid online");shot("a5-server-confirmed-payment") click("Receipts and credit notes");waitText("Receipt DEMO-RECEIPT-001");journey.click(compose.onNodeWithContentDescription("Open PDF DEMO-RECEIPT-001"));waitText("Save PDF") compose.waitUntil(10000){compose.onAllNodesWithContentDescription("DEMO-RECEIPT-001, page 1 of 1").fetchSemanticsNodes().isNotEmpty()} - val file=holder.state.value.preview!!.file;assertTrue(file.exists());shot("a5-native-pdf-receipt");click("Done");compose.waitForIdle();assertNull(holder.state.value.preview);assertFalse(file.exists());assertEquals(0,launches.get()) + val file=holder.state.value.preview!!.file;assertTrue(file.exists()) + val page=compose.onNodeWithContentDescription("DEMO-RECEIPT-001, page 1 of 1") + page.performScrollTo();compose.waitForIdle();page.assertIsDisplayed() + val pageBounds=page.fetchSemanticsNode().boundsInRoot + val fullPage=page.getUnclippedBoundsInRoot();val pixelsPerDp=compose.density.density + val pageViewport=page.onAncestors().filter(hasScrollAction()).onFirst().fetchSemanticsNode().boundsInRoot + val visiblePage=pageBounds.intersect(pageViewport) + assertTrue("A meaningful rendered PDF region must be visible inside its document viewport",visiblePage.width>0 && + fullPage.left.value*pixelsPerDp>=pageViewport.left && fullPage.right.value*pixelsPerDp<=pageViewport.right && + visiblePage.height>=minOf(96f*compose.density.density,pageViewport.height/2)) + shot("a5-native-pdf-receipt") + documentAction("Save PDF");shot("a5-native-pdf-save-action") + documentAction("Share document");shot("a5-native-pdf-share-action") + documentAction("Done");shot("a5-native-pdf-done-action");click("Done");compose.waitForIdle();assertNull(holder.state.value.preview);assertFalse(file.exists());assertEquals(0,launches.get()) } @Test fun delayedConfirmationStaysPendingUntilALaterServerRefresh(){ val holder=start();openDeposit();click("Continue to payment");click("Confirmation delayed");waitText("Your payment is awaiting server confirmation. Check its status again shortly.") @@ -83,6 +162,6 @@ class PaymentsJourneyTest { @Test fun cancelledAndFailedCheckoutAreReadableAtLargestTextInDarkMode(){ val holder=start(dark=true,largeText=true);openDeposit();click("Continue to payment");click("Checkout cancelled");waitText("Checkout closed. Check the current payment status before trying again.") assertEquals("pending",holder.record("501")!!.status);click("Continue to payment");click("Checkout failed");waitText("Checkout could not finish. Check the payment status and try again.") - assertEquals("pending",holder.record("501")!!.status);assertFalse(holder.state.value.busy);shot("a5-payment-failed-dark-large-text");assertEquals(0,launches.get()) + assertEquals("pending",holder.record("501")!!.status);assertFalse(holder.state.value.busy);journey.visible(compose.onNodeWithText("Check payment status")).assertIsEnabled();shot("a5-payment-failed-dark-large-text");assertEquals(0,launches.get()) } } diff --git a/app/src/androidTest/java/org/spaceworks/app/StaffJourneyTest.kt b/app/src/androidTest/java/org/spaceworks/app/StaffJourneyTest.kt index 661d8bd..ed5371b 100644 --- a/app/src/androidTest/java/org/spaceworks/app/StaffJourneyTest.kt +++ b/app/src/androidTest/java/org/spaceworks/app/StaffJourneyTest.kt @@ -30,7 +30,7 @@ class StaffJourneyTest { val holder=StaffStateHolder(api,api.authority,api.uploads).apply{activate(MemberScope(7,"demo-makerspace"))} compose.setContent{val density=LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density,if(large)3.2f else density.fontScale)){ - MaterialTheme(colorScheme=if(dark)DarkScheme else LightScheme,shapes=Shapes(small=RoundedCornerShape(10.dp),medium=RoundedCornerShape(16.dp))){Surface(Modifier.fillMaxSize()){Box(Modifier.fillMaxSize().safeDrawingPadding()){StaffScreen(holder,true,{},previewScope=MemberScope(7,"demo-makerspace"))}}} + org.spaceworks.app.theme.SpaceWorksTheme(dark){Surface(Modifier.fillMaxSize()){Box(Modifier.fillMaxSize().safeDrawingPadding()){StaffScreen(holder,true,{},previewScope=MemberScope(7,"demo-makerspace"))}}} }} journey.systemBars(dark);waitText("Request #601");return holder } @@ -91,7 +91,15 @@ class StaffJourneyTest { replace("staff-product-901","1");tag("staff-handover-next");photoAndConfirm();waitText("Issue #702 recorded");assertEquals(StaffHandoverMode.DIRECT_ISSUE,holder.state.value.handover!!.mode);shot("a6-direct-receipt") } @Test fun checkInRequiresConfirmationAndBookingApprovalIsReadableInLargeDarkLayout(){ - val holder=start(dark=true,large=true);tag("staff-tab-events");waitText("Sample repair workshop");tag("staff-event-801");tag("staff-checkin-code-demo");waitText("Confirm attendance") + val holder=start(dark=true,large=true);tag("staff-tab-events");waitText("Sample repair workshop");tag("staff-event-801") + val sample=journey.visible(compose.onNodeWithTag("staff-checkin-code-demo")).assertIsEnabled() + val label=compose.onNodeWithText("Use sample code",useUnmergedTree=true) + label.assertIsDisplayed() + val actionBounds=sample.fetchSemanticsNode().boundsInRoot;val labelBounds=label.fetchSemanticsNode().boundsInRoot + assertTrue("Whole sample-code label must fit its action",labelBounds.left>=actionBounds.left && labelBounds.right<=actionBounds.right && labelBounds.top>=actionBounds.top && labelBounds.bottom<=actionBounds.bottom) + // Full-OS review verifies the rendered leading glyphs against the actual native corner clip. + shot("a6-checkin-code-action-large") + tag("staff-checkin-code-demo");waitText("Confirm attendance") compose.onAllNodesWithTag("staff-attendance-receipt").assertCountEquals(0);assertNotNull(holder.state.value.person);shot("a6-checkin-review-dark-large") tag("staff-checkin-confirm");waitText("Confirm attendance?");tag("staff-confirm-action");waitText("Attendance recorded for Meera Nair.");shot("a6-attendance-receipt") click("Back");tag("staff-tab-spaces");waitText("Sample project room");tag("staff-space-901");tag("staff-booking-approve-1001");waitText("Approve booking?");tag("staff-confirm-action");waitText("Booking approved.") diff --git a/app/src/androidTest/java/org/spaceworks/app/UsageGuideJourneyTest.kt b/app/src/androidTest/java/org/spaceworks/app/UsageGuideJourneyTest.kt index c545e27..e1602b3 100644 --- a/app/src/androidTest/java/org/spaceworks/app/UsageGuideJourneyTest.kt +++ b/app/src/androidTest/java/org/spaceworks/app/UsageGuideJourneyTest.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.unit.Density @@ -30,7 +32,7 @@ class UsageGuideJourneyTest { @Composable private fun Host(largeText: Boolean = false, content: @Composable () -> Unit) { val density = LocalDensity.current CompositionLocalProvider(LocalDensity provides Density(density.density, if (largeText) 3.2f else density.fontScale)) { - MaterialTheme(colorScheme = if (DeviceProfile.dark) DarkScheme else LightScheme, shapes = Shapes(small = RoundedCornerShape(10.dp), medium = RoundedCornerShape(16.dp))) { + org.spaceworks.app.theme.SpaceWorksTheme(DeviceProfile.dark) { Surface(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize().safeDrawingPadding()) { content() } } } } @@ -66,12 +68,23 @@ class UsageGuideJourneyTest { journey.shot("a8-usage-start-here-reset") } + private fun assertWordBoundaries(node: SemanticsNodeInteraction) { + val text=node.fetchSemanticsNode().config[SemanticsProperties.Text].single().text + val layouts=mutableListOf() + node.performSemanticsAction(SemanticsActions.GetTextLayoutResult) {it(layouts)} + val layout=layouts.single() + for(line in 0 until layout.lineCount-1) { + val end=layout.getLineEnd(line,visibleEnd=true) + assertTrue("Guide text must wrap between complete words: $text",end>=text.length || text[end].isWhitespace() || (end>0 && text[end-1].isWhitespace())) + } + } @Test fun guideRemainsReadableAndActionsReachableAtLargestTextWithoutStartingAnotherFlow() { var replayed = 0; var reset = 0; var back = 0 - compose.setContent { Host(largeText = true) { + var reducedMotion by mutableStateOf(false) + compose.setContent { Host(largeText = true) { CompositionLocalProvider(org.spaceworks.app.platform.LocalReducedMotion provides reducedMotion) { UsageGuideScreen(demo = true, staffAvailable = false, onBack = { back++ }, onReplayIntro = { replayed++ }, onResetTips = { reset++ }) - } } + } } } journey.systemBars(DeviceProfile.dark) journey.visible(compose.onNodeWithTag("usage-guide-heading")) compose.onNodeWithText("Staff handovers").assertDoesNotExist() @@ -84,15 +97,23 @@ class UsageGuideJourneyTest { .assertTextEquals("Search Inventory or choose a category.") journey.visible(compose.onNodeWithTag("usage-section-inventory-body-5")) .assertTextEquals("Use its renewal action when available.") + assertWordBoundaries(compose.onNodeWithTag("usage-section-inventory-body-5")) journey.shot("a8-usage-inventory-large-text") journey.click(inventory) inventory.assert(SemanticsMatcher.keyIsDefined(SemanticsActions.Expand)) compose.onNodeWithTag("usage-section-inventory-body-0").assertDoesNotExist() compose.onNodeWithTag("usage-section-inventory-body-5").assertDoesNotExist() + // Reduced motion keeps one immediate semantic body and the same native controls. + compose.runOnIdle { reducedMotion = true } + journey.click(inventory) + compose.onAllNodesWithTag("usage-section-inventory-body-0").assertCountEquals(1) + journey.click(inventory) + compose.onNodeWithTag("usage-section-inventory-body-0").assertDoesNotExist() journey.clickTag("usage-replay-intro"); assertEquals(1, replayed) journey.clickTag("usage-reset-tips"); assertEquals(1, reset) journey.visible(compose.onNodeWithTag("usage-reset-notice")) .assertTextEquals("Tips reset for this account and makerspace.") + assertWordBoundaries(compose.onNodeWithTag("usage-reset-notice")) journey.shot("a8-usage-guide-large-text") journey.clickTag("usage-guide-back"); assertEquals(1, back) } diff --git a/app/src/main/assets/licenses/OFL-Unbounded.txt b/app/src/main/assets/licenses/OFL-Unbounded.txt new file mode 100644 index 0000000..deb478a --- /dev/null +++ b/app/src/main/assets/licenses/OFL-Unbounded.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Unbounded Project Authors (https://github.com/googlefonts/unbounded) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/app/src/main/java/org/spaceworks/app/MainActivity.kt b/app/src/main/java/org/spaceworks/app/MainActivity.kt index 4e736f2..095ce8c 100644 --- a/app/src/main/java/org/spaceworks/app/MainActivity.kt +++ b/app/src/main/java/org/spaceworks/app/MainActivity.kt @@ -79,8 +79,7 @@ class MainActivity:ComponentActivity() { if(demo)demoFactory() else if(connection.connected)sessionFactory?.invoke(connection.url) else null } val identity=bundle?.holder?.state?.collectAsStateWithLifecycle()?.value?.user - MaterialTheme(colorScheme=if(dark)DarkScheme else LightScheme, - shapes=Shapes(small=androidx.compose.foundation.shape.RoundedCornerShape(10.dp),medium=androidx.compose.foundation.shape.RoundedCornerShape(16.dp))) { + SpaceWorksTheme(dark) { Surface(Modifier.fillMaxSize(),color=MaterialTheme.colorScheme.background) { Column(Modifier.safeDrawingPadding()) { Row(Modifier.fillMaxWidth().padding(horizontal=16.dp),horizontalArrangement=Arrangement.SpaceBetween) { diff --git a/app/src/main/java/org/spaceworks/app/auth/AuthOperationOutcome.kt b/app/src/main/java/org/spaceworks/app/auth/AuthOperationOutcome.kt new file mode 100644 index 0000000..63c3cae --- /dev/null +++ b/app/src/main/java/org/spaceworks/app/auth/AuthOperationOutcome.kt @@ -0,0 +1,18 @@ +package org.spaceworks.app.auth + +enum class AuthOperationKind { PROGRESSED, ACCEPTED, REJECTED } + +/** Nonsecret, operation-owned completion proof. Contains no credentials, recovery codes or user data. */ +class AuthOperationOutcome internal constructor(internal val owner:Any,internal val revision:Long, + internal val epoch:Long,val kind:AuthOperationKind) + +internal fun authOperationKind(before:AuthState,after:AuthState,accepted:Boolean):AuthOperationKind? = when { + after.busy -> null + after.error!=null && after.error!=before.error -> AuthOperationKind.REJECTED + after.gate!=null && after.gate!=before.gate -> AuthOperationKind.REJECTED + after.user!=null && before.user==null && accepted -> + if(after.user.mustChangePassword)AuthOperationKind.PROGRESSED else AuthOperationKind.ACCEPTED + after.mfa!=null && before.mfa==null -> AuthOperationKind.PROGRESSED + after.recoveryCodes.isNotEmpty() && before.recoveryCodes.isEmpty() -> AuthOperationKind.PROGRESSED + else -> null +} diff --git a/app/src/main/java/org/spaceworks/app/auth/AuthStateHolder.kt b/app/src/main/java/org/spaceworks/app/auth/AuthStateHolder.kt index 051751f..d77a7c1 100644 --- a/app/src/main/java/org/spaceworks/app/auth/AuthStateHolder.kt +++ b/app/src/main/java/org/spaceworks/app/auth/AuthStateHolder.kt @@ -64,6 +64,9 @@ class AuthStateHolder(private val api:AuthGateway,private val tokens:TokenStore, private var endingLocalSession=false private var removedMembershipEpoch:Long?=null private val removedMembershipIds=mutableSetOf() + private val outcomeOwner=Any() + fun isCurrentOutcome(outcome:AuthOperationOutcome)=outcome.owner===outcomeOwner && + outcome.revision==localIdentityRevision && outcome.epoch==tokens.snapshot().epoch private var retryAt=0L private var challengeExpiresAt=0L val nativeSocialReady get()=api.nativeSocialReady @@ -73,7 +76,8 @@ class AuthStateHolder(private val api:AuthGateway,private val tokens:TokenStore, val restored=withContext(Dispatchers.IO){tokens.snapshot()} if(restored.pair!=null){installedEpoch=restored.epoch;refreshIdentity()} } - suspend fun signIn(username:String,password:String)=runOperation { + suspend fun signIn(username:String,password:String)=signInWithOutcome(username,password,null) + suspend fun signInWithOutcome(username:String,password:String,onOutcome:((AuthOperationOutcome)->Unit)?)=runOperation(onOutcome=onOutcome) { mutable.value=state.value.copy(gate=null) ensure(username.trim().isNotBlank() && password.isNotEmpty(),text(CoreStrings.CORE_ENTER_YOUR_EMAIL_OR_USERNAME_AND_PASSWORD)) ensure(state.value.config!=null && state.value.passwordEnabled,text(CoreStrings.CORE_PASSWORD_SIGN_IN_IS_UNAVAILABLE_ON_THIS_SERVER)) @@ -90,7 +94,8 @@ class AuthStateHolder(private val api:AuthGateway,private val tokens:TokenStore, } } } - suspend fun googleSignIn(credential:suspend(String,String)->String)=runOperation { + suspend fun googleSignIn(credential:suspend(String,String)->String)=googleSignInWithOutcome(credential,null) + suspend fun googleSignInWithOutcome(credential:suspend(String,String)->String,onOutcome:((AuthOperationOutcome)->Unit)?)=runOperation(onOutcome=onOutcome) { mutable.value=state.value.copy(gate=null) val client=state.value.googleClientId ?: throw AuthMessage(text(CoreStrings.CORE_GOOGLE_SIGN_IN_IS_NOT_CONFIGURED_FOR_THIS_SERVER)) ensure(api.nativeSocialReady,text(CoreStrings.CORE_DEVICE_SIGN_IN_IS_NOT_CONFIGURED_FOR_THIS_BUILD_CONTACT_YOUR)) @@ -105,7 +110,8 @@ class AuthStateHolder(private val api:AuthGateway,private val tokens:TokenStore, ensureOperationCurrent() mutable.value=state.value.copy(enrollment=enrollment) } - suspend fun verify(code:String)=runOperation { + suspend fun verify(code:String)=verifyWithOutcome(code,null) + suspend fun verifyWithOutcome(code:String,onOutcome:((AuthOperationOutcome)->Unit)?)=runOperation(onOutcome=onOutcome) { val challenge=requireChallenge() ensure(code.trim().length in 6..64,text(CoreStrings.CORE_ENTER_YOUR_AUTHENTICATOR_OR_RECOVERY_CODE)) val result=api.verify(challenge.challenge,code.trim(),challenge.enrollmentRequired) @@ -116,7 +122,8 @@ class AuthStateHolder(private val api:AuthGateway,private val tokens:TokenStore, if (result.recoveryCodes.orEmpty().isNotEmpty()) mutable.value=state.value.copy(recoveryCodes=result.recoveryCodes.orEmpty()) else finishMfaInternal() } - suspend fun finishMfa()=runOperation { finishMfaInternal() } + suspend fun finishMfa()=finishMfaWithOutcome(null) + suspend fun finishMfaWithOutcome(onOutcome:((AuthOperationOutcome)->Unit)?)=runOperation(onOutcome=onOutcome) { finishMfaInternal() } private suspend fun finishMfaInternal() { val expected=installedEpoch try { refreshIdentity();mutable.value=state.value.copy(mfa=null,enrollment=null,recoveryCodes=emptyList()) } @@ -359,9 +366,16 @@ class AuthStateHolder(private val api:AuthGateway,private val tokens:TokenStore, val id=(grant?.get("id") as? JsonPrimitive)?.contentOrNull return try {id!=null && java.util.UUID.fromString(id).toString()==id.lowercase()}catch(_:IllegalArgumentException){false} } - private suspend fun runOperation(ignoreThrottle:Boolean=false,block:suspend()->Unit) { + private suspend fun runOperation(ignoreThrottle:Boolean=false,onOutcome:((AuthOperationOutcome)->Unit)?=null,block:suspend()->Unit) { if(endingLocalSession || !operation.tryLock()) return val revision=localIdentityRevision + val before=state.value + var completion:AuthOperationOutcome?=null + fun capture(kind:AuthOperationKind?) { + val epoch=operationEpoch ?: return + if(onOutcome!=null && kind!=null && localIdentityRevision==revision && tokens.snapshot().epoch==epoch) + completion=AuthOperationOutcome(outcomeOwner,revision,epoch,kind) + } operationRevision=revision operationEpoch=tokens.snapshot().epoch try { @@ -369,6 +383,7 @@ class AuthStateHolder(private val api:AuthGateway,private val tokens:TokenStore, if(state.value.user!=null)requireInstalledEpoch() mutable.value=state.value.copy(busy=true,error=null,notice=null) block() + capture(authOperationKind(before,state.value.copy(busy=false),acceptedInstallationEpoch!=null)) } catch(cancelled:CancellationException) {throw cancelled} catch(_:AuthCancelled) {if(localIdentityRevision!=revision)return;mutable.value=state.value.copy(error=null,notice=text(CoreStrings.CORE_SIGN_IN_CANCELLED))} catch(f:Exception) { @@ -377,6 +392,8 @@ class AuthStateHolder(private val api:AuthGateway,private val tokens:TokenStore, mutable.value=state.value.copy(retryAtMillis=retryAt, gate=if(f is ApiFailure && f.code=="device_superadmin_not_supported")AuthGate.WEB_CONSOLE else state.value.gate, error=when(f){is MobileCopyException->text.failure(f);is ApiFailure->text.apiFailure(f);is AuthMessage->f.message ?: text(CoreStrings.CORE_COULDN_T_CONNECT_TRY_AGAIN);else->text(CoreStrings.CORE_COULDN_T_CONNECT_TRY_AGAIN)}) + capture(AuthOperationKind.REJECTED) } finally {operationRevision=null;operationEpoch=null;mutable.value=state.value.copy(busy=endingLocalSession);operation.unlock()} + completion?.takeIf(::isCurrentOutcome)?.let {onOutcome?.invoke(it)} } } diff --git a/app/src/main/java/org/spaceworks/app/guidance/UsageGuide.kt b/app/src/main/java/org/spaceworks/app/guidance/UsageGuide.kt index 85f40a4..89b0152 100644 --- a/app/src/main/java/org/spaceworks/app/guidance/UsageGuide.kt +++ b/app/src/main/java/org/spaceworks/app/guidance/UsageGuide.kt @@ -1,5 +1,11 @@ package org.spaceworks.app.guidance +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import org.spaceworks.app.platform.LocalReducedMotion +import org.spaceworks.app.theme.BrandMotion + import android.content.Context import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -53,7 +59,7 @@ import org.spaceworks.app.platform.rememberMobileText GuideButton(copy(UsageStrings.USAGE_BROWSE_INVENTORY), "usage-browse-inventory", onInventory) if (onBookings != null) GuideButton(copy(UsageStrings.USAGE_VIEW_BOOKINGS), "usage-view-bookings", onBookings) else if (onProfile != null) GuideButton(copy(UsageStrings.USAGE_VIEW_PROFILE), "usage-view-profile", onProfile) - TextButton(onClick = progress::dismiss, modifier = Modifier.heightIn(min = 48.dp).testTag("usage-dismiss-tip"), + TextButton(shape = MaterialTheme.shapes.small, onClick = progress::dismiss, modifier = Modifier.heightIn(min = 48.dp).testTag("usage-dismiss-tip"), colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onPrimaryContainer)) { Text(copy(UsageStrings.USAGE_DISMISS_TIP)) } @@ -76,7 +82,7 @@ import org.spaceworks.app.platform.rememberMobileText Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { Column(Modifier.widthIn(max = 960.dp).fillMaxWidth().verticalScroll(rememberScrollState()) .padding(24.dp).testTag("usage-guide-scroll"), verticalArrangement = Arrangement.spacedBy(20.dp)) { - TextButton(onClick = onBack, modifier = Modifier.heightIn(min = 48.dp).testTag("usage-guide-back"), + TextButton(shape = MaterialTheme.shapes.small, onClick = onBack, modifier = Modifier.heightIn(min = 48.dp).testTag("usage-guide-back"), colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onPrimaryContainer)) { Text(copy(UsageStrings.USAGE_BACK)) } @@ -87,7 +93,7 @@ import org.spaceworks.app.platform.rememberMobileText GuideButton(copy(UsageStrings.USAGE_RESET_HELPFUL_TIPS), "usage-reset-tips") { onResetTips(); resetNotice = true } - if (resetNotice) Text(copy(UsageStrings.USAGE_TIPS_RESET), modifier = Modifier.testTag("usage-reset-notice") + if (resetNotice) Text(copy(UsageStrings.USAGE_TIPS_RESET), style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("usage-reset-notice") .semantics { liveRegion = LiveRegionMode.Polite }) } if (demo) Text(copy(UsageStrings.USAGE_DEMO), color = MaterialTheme.colorScheme.onSurfaceVariant) @@ -115,9 +121,10 @@ private data class GuideTopic(val id: String, val title: MobileCopyKey, val body val copy = rememberMobileText() var expanded by rememberSaveable(topic.id) { mutableStateOf(false) } val isExpanded = expanded - Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)) { + val reduced=LocalReducedMotion.current + Card(Modifier.fillMaxWidth().animateContentSize(animationSpec=if(reduced)snap()else tween(BrandMotion.EXPAND)), shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)) { Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - TextButton(onClick = { expanded = !isExpanded }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), + TextButton(shape = MaterialTheme.shapes.small, onClick = { expanded = !isExpanded }, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onSurface), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("usage-section-${topic.id}").semantics { heading() @@ -130,7 +137,7 @@ private data class GuideTopic(val id: String, val title: MobileCopyKey, val body } } if (isExpanded) for ((index, paragraph) in copy(topic.body).split("\n\n").withIndex()) { - Text(paragraph, modifier = Modifier.fillMaxWidth().testTag("usage-section-${topic.id}-body-$index")) + Text(paragraph, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth().testTag("usage-section-${topic.id}-body-$index")) } } } diff --git a/app/src/main/java/org/spaceworks/app/payments/PaymentsScreen.kt b/app/src/main/java/org/spaceworks/app/payments/PaymentsScreen.kt index ad78c80..aa66754 100644 --- a/app/src/main/java/org/spaceworks/app/payments/PaymentsScreen.kt +++ b/app/src/main/java/org/spaceworks/app/payments/PaymentsScreen.kt @@ -13,8 +13,11 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.* import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.launch import org.spaceworks.app.platform.* @@ -48,10 +51,17 @@ import org.spaceworks.app.ui.* val row=s.selectedId?.let(holder::record) val enabled=hostAvailable && !s.busy && clock>=s.retryAtMillis val providerBusy=!demo && nativeState !is NativePaymentState.Idle - if(demoCheckout!=null && demo)AlertDialog(onDismissRequest={demoCheckout=null},title={Text(text(PaymentStrings.PAYMENT_PREVIEW_CHECKOUT))},text={Column(Modifier.heightIn(max=420.dp).verticalScroll(rememberScrollState())){ - Text(text(PaymentStrings.PAYMENT_SAMPLE_PAYMENTS_ONLY_NO_PAYMENT_PROVIDER_OPENS_AND_NO_MONEY)) - DemoPaymentScenario.entries.forEach{scenario->BrandTextButton(demoPaymentLabel(scenario,text),{val id=demoCheckout;demoCheckout=null;if(id!=null)work.launch{holder.simulate(id,scenario)}})} - }},confirmButton={BrandTextButton(text(PaymentStrings.PAYMENT_KEEP_REVIEWING),{demoCheckout=null})}) + if(demoCheckout!=null && demo)Dialog(onDismissRequest={demoCheckout=null},properties=DialogProperties(usePlatformDefaultWidth=false)) { + Surface(Modifier.widthIn(max=560.dp).fillMaxWidth(.94f).fillMaxHeight(.94f),shape=MaterialTheme.shapes.large) { + Column(Modifier.testTag("demo-payment-outcome-scroll").verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement=Arrangement.spacedBy(16.dp)) { + Text(text(PaymentStrings.PAYMENT_PREVIEW_CHECKOUT),style=MaterialTheme.typography.titleMedium,modifier=Modifier.semantics{heading()}) + Text(text(PaymentStrings.PAYMENT_SAMPLE_PAYMENTS_ONLY_NO_PAYMENT_PROVIDER_OPENS_AND_NO_MONEY),style=MaterialTheme.typography.bodyMedium) + DemoPaymentScenario.entries.forEach{scenario->BrandTextButton(demoPaymentLabel(scenario,text),{val id=demoCheckout;demoCheckout=null;if(id!=null)work.launch{holder.simulate(id,scenario)}},modifier=Modifier.fillMaxWidth())} + BrandTextButton(text(PaymentStrings.PAYMENT_KEEP_REVIEWING),{demoCheckout=null},modifier=Modifier.fillMaxWidth()) + } + } + } Box(Modifier.fillMaxSize(),contentAlignment=androidx.compose.ui.Alignment.TopCenter){ Column(Modifier.widthIn(max=800.dp).fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp),verticalArrangement=Arrangement.spacedBy(20.dp)) { BrandTextButton(text(PaymentStrings.PAYMENT_BACK),{back()},!s.busy) diff --git a/app/src/main/java/org/spaceworks/app/platform/NativeDocumentPreview.kt b/app/src/main/java/org/spaceworks/app/platform/NativeDocumentPreview.kt index 78aad74..529078b 100644 --- a/app/src/main/java/org/spaceworks/app/platform/NativeDocumentPreview.kt +++ b/app/src/main/java/org/spaceworks/app/platform/NativeDocumentPreview.kt @@ -115,15 +115,15 @@ class NativeDocumentFiles(private val directory:File,private val now:()->Long=Sy if(file.kind==NativeDocumentKind.PDF){ Text(text(DocumentStrings.DOCUMENT_PAGE_1_S_OF_2_S, page+1, pages)) if(pages>1)Column(verticalArrangement=Arrangement.spacedBy(8.dp)) { - TextButton(onClick={page--},enabled=page>0&&!rendering,colors=textColors,modifier=Modifier.fillMaxWidth().heightIn(min=48.dp)){Text(text(DocumentStrings.DOCUMENT_PREVIOUS_PAGE))} - TextButton(onClick={page++},enabled=page+10&&!rendering,colors=textColors,modifier=Modifier.fillMaxWidth().heightIn(min=48.dp)){Text(text(DocumentStrings.DOCUMENT_PREVIOUS_PAGE))} + TextButton(shape=MaterialTheme.shapes.small,onClick={page++},enabled=page+1Any, + private val active:()->Boolean) { + private val gate=FeedbackTicketGate() + fun begin()=gate.begin(owner()) + fun cancel()=gate.cancel() + fun perform(kind:FeedbackKind)=finish(begin(),kind) + fun finish(ticket:FeedbackTicket,kind:FeedbackKind?) { + if(!gate.finish(ticket,owner(),kind!=null && active() && view.isAttachedToWindow && view.isHapticFeedbackEnabled))return + val constant=when(kind) { + FeedbackKind.SELECTION->HapticFeedbackConstants.CLOCK_TICK + FeedbackKind.CONFIRM->if(Build.VERSION.SDK_INT>=30)HapticFeedbackConstants.CONFIRM else HapticFeedbackConstants.CONTEXT_CLICK + FeedbackKind.REJECT->if(Build.VERSION.SDK_INT>=30)HapticFeedbackConstants.REJECT else HapticFeedbackConstants.CONTEXT_CLICK + null->return + } + // The one-argument native API respects system touch-feedback settings and device support. + // No IGNORE flags, custom vibration waveform or VIBRATE permission. + view.performHapticFeedback(constant) + } +} + +@Composable internal fun rememberActionFeedback(owner:Any):NativeActionFeedback { + val view=LocalView.current + val lifecycle=LocalLifecycleOwner.current.lifecycle + val currentOwner by rememberUpdatedState(owner) + var mounted by remember(view,lifecycle){mutableStateOf(true)} + val feedback=remember(view,lifecycle) {NativeActionFeedback(view,{currentOwner}, + {mounted && lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)})} + DisposableEffect(feedback,owner) {onDispose {feedback.cancel()}} + DisposableEffect(feedback) {onDispose {mounted=false;feedback.cancel()}} + return feedback +} diff --git a/app/src/main/java/org/spaceworks/app/platform/NativeMotion.kt b/app/src/main/java/org/spaceworks/app/platform/NativeMotion.kt new file mode 100644 index 0000000..8f79e69 --- /dev/null +++ b/app/src/main/java/org/spaceworks/app/platform/NativeMotion.kt @@ -0,0 +1,24 @@ +package org.spaceworks.app.platform + +import android.database.ContentObserver +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext + +val LocalReducedMotion=staticCompositionLocalOf {false} + +@Composable fun rememberReducedMotion():Boolean { + val context=LocalContext.current + fun read()=Settings.Global.getFloat(context.contentResolver,Settings.Global.ANIMATOR_DURATION_SCALE,1f)==0f + var reduced by remember(context){mutableStateOf(read())} + DisposableEffect(context) { + val observer=object:ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange:Boolean){reduced=read()} + } + context.contentResolver.registerContentObserver(Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE),false,observer) + onDispose {context.contentResolver.unregisterContentObserver(observer)} + } + return reduced +} diff --git a/app/src/main/java/org/spaceworks/app/platform/NativePayments.kt b/app/src/main/java/org/spaceworks/app/platform/NativePayments.kt index b11f550..030935d 100644 --- a/app/src/main/java/org/spaceworks/app/platform/NativePayments.kt +++ b/app/src/main/java/org/spaceworks/app/platform/NativePayments.kt @@ -153,6 +153,7 @@ class StripeSheetAdapter(private val activity: ComponentActivity, } else null sheet.presentWithPaymentIntent(input.clientSecret, PaymentSheet.Configuration( merchantDisplayName = input.merchantName, customer = customer, allowsDelayedPaymentMethods = false, + appearance = stripeBrandAppearance(), )) } catch (_: Exception) { callbacks.finish(NativePaymentProvider.STRIPE, SheetCloseReason.FAILED, text(org.spaceworks.app.payments.PaymentStrings.PAYMENT_SDK_CHECKOUT_UNAVAILABLE)) @@ -210,7 +211,7 @@ class RazorpayCheckoutActivity : ComponentActivity(), PaymentResultWithDataListe isAppearanceLightNavigationBars=!dark } setContent { - androidx.compose.material3.MaterialTheme(colorScheme=if(dark)org.spaceworks.app.theme.DarkScheme else org.spaceworks.app.theme.LightScheme) { + org.spaceworks.app.theme.SpaceWorksTheme(dark) { CheckoutRecoveryScreen { setResult(Activity.RESULT_CANCELED, Intent().putExtra("interrupted", true)); finish() } @@ -225,6 +226,7 @@ class RazorpayCheckoutActivity : ComponentActivity(), PaymentResultWithDataListe try { val options = JSONObject().put("name", input.merchantName).put("order_id", input.orderId) .put("amount", input.amountMinor).put("currency", input.currency) + .put("theme.color",razorpayBrandColor()) Checkout().apply { setKeyID(input.keyId) }.open(this, options) } catch (_: Exception) { fail() } } diff --git a/app/src/main/java/org/spaceworks/app/platform/OutcomeFeedback.kt b/app/src/main/java/org/spaceworks/app/platform/OutcomeFeedback.kt new file mode 100644 index 0000000..a28da8b --- /dev/null +++ b/app/src/main/java/org/spaceworks/app/platform/OutcomeFeedback.kt @@ -0,0 +1,17 @@ +package org.spaceworks.app.platform + +internal enum class FeedbackKind { SELECTION, CONFIRM, REJECT } +internal class FeedbackTicket internal constructor(internal val owner:Any) + +/** One explicit UI action, consumed once; disposal/owner changes discard pending feedback. */ +internal class FeedbackTicketGate { + private var pending:FeedbackTicket?=null + fun begin(owner:Any)=FeedbackTicket(owner).also {pending=it} + fun cancel(){pending=null} + fun finish(ticket:FeedbackTicket,owner:Any,eligible:Boolean):Boolean { + if(pending!==ticket)return false + pending=null + return ticket.owner==owner && eligible + } +} + diff --git a/app/src/main/java/org/spaceworks/app/platform/PaymentBrandAppearance.kt b/app/src/main/java/org/spaceworks/app/platform/PaymentBrandAppearance.kt new file mode 100644 index 0000000..43eaed3 --- /dev/null +++ b/app/src/main/java/org/spaceworks/app/platform/PaymentBrandAppearance.kt @@ -0,0 +1,44 @@ +package org.spaceworks.app.platform + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.toArgb +import com.stripe.android.paymentsheet.PaymentSheet +import org.spaceworks.app.R +import org.spaceworks.app.theme.BrandTokens +import org.spaceworks.app.theme.DarkScheme +import org.spaceworks.app.theme.LightScheme +import java.util.Locale + +/** SDK appearance only. Stripe retains native layout, type sizes, weights and system appearance. */ +internal fun stripeBrandAppearance():PaymentSheet.Appearance { + fun colors(scheme:ColorScheme)=PaymentSheet.Colors( + // SDK primary also colors links/checkmarks. Keep these accessible on dark surfaces; + // the independently configured primary button below retains the exact teal fill. + primary=scheme.onPrimaryContainer.toArgb(),surface=scheme.background.toArgb(), + component=scheme.surfaceContainer.toArgb(),componentBorder=scheme.outline.toArgb(), + componentDivider=scheme.outlineVariant.toArgb(),onComponent=scheme.onSurface.toArgb(), + onSurface=scheme.onBackground.toArgb(),subtitle=scheme.onSurfaceVariant.toArgb(), + placeholderText=scheme.onSurfaceVariant.toArgb(),appBarIcon=scheme.onSurface.toArgb(), + error=scheme.error.toArgb() + ) + return PaymentSheet.Appearance( + colorsLight=colors(LightScheme),colorsDark=colors(DarkScheme), + shapes=PaymentSheet.Shapes(cornerRadiusDp=10f,borderStrokeWidthDp=1f), + typography=PaymentSheet.Typography(sizeScaleFactor=1f,fontResId=R.font.gilroy), + primaryButton=PaymentSheet.PrimaryButton( + colorsLight=PaymentSheet.PrimaryButtonColors( + background=LightScheme.primary.toArgb(),onBackground=LightScheme.onPrimary.toArgb(), + border=LightScheme.primary.toArgb(),successBackgroundColor=BrandTokens.Light.success.toArgb(), + onSuccessBackgroundColor=BrandTokens.Light.onSuccess.toArgb()), + colorsDark=PaymentSheet.PrimaryButtonColors( + background=DarkScheme.primary.toArgb(),onBackground=DarkScheme.onPrimary.toArgb(), + border=DarkScheme.primary.toArgb(),successBackgroundColor=BrandTokens.Dark.success.toArgb(), + onSuccessBackgroundColor=BrandTokens.Dark.onSuccess.toArgb()), + shape=PaymentSheet.PrimaryButtonShape(cornerRadiusDp=10f,borderStrokeWidthDp=1f), + typography=PaymentSheet.PrimaryButtonTypography(fontResId=R.font.gilroy) + ) + ) +} + +/** Android Standard Checkout documents theme.color, but no supported custom-font API. */ +internal fun razorpayBrandColor():String = String.format(Locale.ROOT,"#%06X",LightScheme.primary.toArgb() and 0xffffff) diff --git a/app/src/main/java/org/spaceworks/app/staff/StaffScreen.kt b/app/src/main/java/org/spaceworks/app/staff/StaffScreen.kt index 33dc870..4b128fa 100644 --- a/app/src/main/java/org/spaceworks/app/staff/StaffScreen.kt +++ b/app/src/main/java/org/spaceworks/app/staff/StaffScreen.kt @@ -4,6 +4,8 @@ import org.spaceworks.app.network.retrySecondsRemaining import org.spaceworks.app.platform.MobileText import org.spaceworks.app.platform.rememberMobileText +import org.spaceworks.app.platform.rememberActionFeedback +import org.spaceworks.app.platform.FeedbackKind import android.content.Context import android.graphics.Bitmap @@ -63,6 +65,7 @@ private data class StaffConfirmation(val title:String,val detail:String,val acti val text=rememberMobileText() val s by holder.state.collectAsStateWithLifecycle();val work=rememberCoroutineScope() + val feedback=rememberActionFeedback(holder to (s.scope to s.section)) val focus=LocalFocusManager.current;val keyboard=LocalSoftwareKeyboardController.current val scroll=rememberScrollState();var confirmation by remember(s.scope,s.section){mutableStateOf(null)} var previewOptionsOpen by remember(holder){mutableStateOf(false)} @@ -73,7 +76,19 @@ private data class StaffConfirmation(val title:String,val detail:String,val acti LaunchedEffect(s.scope,s.section){when(s.section){StaffSection.QUEUE->holder.loadQueue();StaffSection.DIRECT->holder.loadDirect();StaffSection.EVENTS->holder.loadEvents();StaffSection.SPACES->holder.loadSpaces();else->Unit}} LaunchedEffect(s.error){if(s.error!=null){focus.clearFocus();keyboard?.hide();scroll.scrollTo(0)}} val enabled=!s.busy&&clock>=s.retryAtMillis - fun launch(action:suspend()->Unit){focus.clearFocus();keyboard?.hide();work.launch{action()}} + fun launch(action:suspend()->Unit){ + focus.clearFocus();keyboard?.hide() + val before=holder.state.value + val ticket=feedback.begin() + work.launch { + action() + val after=holder.state.value + val recorded=after.scope==before.scope && after.error==null && + ((after.handover?.receipt!=null && before.handover?.receipt==null) || + (after.attendanceNotice!=null && before.attendanceNotice==null)) + feedback.finish(ticket,if(recorded)FeedbackKind.CONFIRM else null) + } + } fun close(){if(!s.busy){if(s.section in setOf(StaffSection.QUEUE,StaffSection.DIRECT,StaffSection.EVENTS,StaffSection.SPACES))onBack()else holder.close()}} BackHandler{close()} confirmation?.let{pending-> @@ -271,6 +286,7 @@ if(s.nextPages[key]!=null)BrandTextButton(text(StaffStrings.STAFF_LOAD_MORE),mor @Composable private fun StaffScanInput(holder:StaffStateHolder,kind:StaffScanKind,label:String,demo:Boolean,enabled:Boolean,tag:String,demoPayload:String){ val text=rememberMobileText();val work=rememberCoroutineScope() val observed by holder.state.collectAsStateWithLifecycle() + val feedback=rememberActionFeedback(holder to (observed.scope to kind)) val target=if(observed.scope==null)null else holder.scanTarget(kind) var code by remember(holder,tag,target){mutableStateOf("")} var camera by remember(holder,tag,target){mutableStateOf(null)} @@ -284,7 +300,18 @@ if(s.nextPages[key]!=null)BrandTextButton(text(StaffStrings.STAFF_LOAD_MORE),mor fun resolve(raw:String,owner:StaffScanTarget){ if(!mounted||currentHolder!==holder||currentTarget!=owner||!holder.acceptsScanTarget(owner))return code="";camera=null;failure=null - work.launch(start=kotlinx.coroutines.CoroutineStart.UNDISPATCHED){holder.resolveScan(owner,raw)} + val before=holder.state.value + val ticket=feedback.begin() + work.launch(start=kotlinx.coroutines.CoroutineStart.UNDISPATCHED){ + val accepted=holder.resolveScan(owner,raw) + val after=holder.state.value + val resolved=accepted && after.scope==before.scope && after.error==null && when(kind) { + StaffScanKind.CHECK_IN->after.person!=null && after.person!=before.person + StaffScanKind.CONTAINER->!after.handover?.boxCode.isNullOrBlank() && after.handover?.boxCode!=before.handover?.boxCode + StaffScanKind.EQUIPMENT->!after.handover?.scans.isNullOrEmpty() && after.handover?.scans!=before.handover?.scans + } + feedback.finish(ticket,if(resolved)FeedbackKind.SELECTION else null) + } } BrandField(code,{code=it.take(2048)},label,Modifier.testTag(tag),enabled) BrandError(failure) diff --git a/app/src/main/java/org/spaceworks/app/theme/BrandTheme.kt b/app/src/main/java/org/spaceworks/app/theme/BrandTheme.kt new file mode 100644 index 0000000..cd29ef9 --- /dev/null +++ b/app/src/main/java/org/spaceworks/app/theme/BrandTheme.kt @@ -0,0 +1,53 @@ +package org.spaceworks.app.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.spaceworks.app.R +import org.spaceworks.app.platform.LocalReducedMotion +import org.spaceworks.app.platform.rememberReducedMotion + +// The supplied faces carry legacy OS/2 weight=400 metadata. Declare each actual face's +// role explicitly, without rewriting the owner's name, outlines or metrics tables. +val BrandFontFamily = FontFamily( + Font(R.font.gilroy_regular, FontWeight.Normal), + Font(R.font.gilroy_medium, FontWeight.Medium), + Font(R.font.gilroy_semibold, FontWeight.SemiBold), + Font(R.font.gilroy_bold, FontWeight.Bold) +) + +/** Preserve Material's native role sizes, line heights and scalable sp units. */ +val BrandTypography = Typography().run { + copy( + displayLarge=displayLarge.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.Bold), + displayMedium=displayMedium.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.SemiBold), + displaySmall=displaySmall.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.SemiBold), + headlineLarge=headlineLarge.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.SemiBold), + headlineMedium=headlineMedium.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.SemiBold), + headlineSmall=headlineSmall.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.SemiBold), + titleLarge=titleLarge.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.SemiBold), + titleMedium=titleMedium.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.SemiBold), + titleSmall=titleSmall.copy(fontFamily=BrandFontFamily,fontWeight=FontWeight.SemiBold), + bodyLarge=bodyLarge.copy(fontFamily=BrandFontFamily), + bodyMedium=bodyMedium.copy(fontFamily=BrandFontFamily), + bodySmall=bodySmall.copy(fontFamily=BrandFontFamily), + labelLarge=labelLarge.copy(fontFamily=BrandFontFamily), + labelMedium=labelMedium.copy(fontFamily=BrandFontFamily), + labelSmall=labelSmall.copy(fontFamily=BrandFontFamily) + ) +} + +@Composable fun SpaceWorksTheme(dark:Boolean,content:@Composable ()->Unit) { + val reduced=rememberReducedMotion() + CompositionLocalProvider(LocalReducedMotion provides reduced) { + MaterialTheme(colorScheme=if(dark)DarkScheme else LightScheme,typography=BrandTypography, + shapes=Shapes(small=RoundedCornerShape(10.dp),medium=RoundedCornerShape(16.dp)),content=content) + } +} diff --git a/app/src/main/java/org/spaceworks/app/ui/AuthScreen.kt b/app/src/main/java/org/spaceworks/app/ui/AuthScreen.kt index fe23884..70fba80 100644 --- a/app/src/main/java/org/spaceworks/app/ui/AuthScreen.kt +++ b/app/src/main/java/org/spaceworks/app/ui/AuthScreen.kt @@ -30,6 +30,18 @@ import org.spaceworks.app.platform.GoogleIdentity val state by holder.state.collectAsStateWithLifecycle() val account=bundle.accounts?.state?.collectAsStateWithLifecycle()?.value val scope=rememberCoroutineScope() + val feedback=rememberActionFeedback(bundle) + fun launchAuth(action:suspend((AuthOperationOutcome)->Unit)->Unit) { + val ticket=feedback.begin() + scope.launch {action {outcome-> + val kind=if(!holder.isCurrentOutcome(outcome))null else when(outcome.kind) { + AuthOperationKind.PROGRESSED->FeedbackKind.SELECTION + AuthOperationKind.ACCEPTED->FeedbackKind.CONFIRM + AuthOperationKind.REJECTED->FeedbackKind.REJECT + } + feedback.finish(ticket,kind) + }} + } val context=LocalContext.current val focus=LocalFocusManager.current val keyboard=LocalSoftwareKeyboardController.current @@ -62,8 +74,8 @@ import org.spaceworks.app.platform.GoogleIdentity val submit:()->Unit={ usernameError=if(username.isBlank())copyText(UiStrings.MOBILE_ENTER_YOUR_EMAIL_OR_USERNAME) else null passwordError=if(password.isEmpty())copyText(UiStrings.MOBILE_ENTER_YOUR_PASSWORD) else null - when {usernameError!=null->usernameFocus.requestFocus();passwordError!=null->passwordFocus.requestFocus();else->{ - focus.clearFocus();keyboard?.hide();scope.launch {holder.signIn(username,password);if(holder.state.value.mfa!=null || holder.state.value.user!=null)password=""} + when {usernameError!=null->{feedback.perform(FeedbackKind.REJECT);usernameFocus.requestFocus()};passwordError!=null->{feedback.perform(FeedbackKind.REJECT);passwordFocus.requestFocus()};else->{ + focus.clearFocus();keyboard?.hide();launchAuth {outcome->holder.signInWithOutcome(username,password,outcome);if(holder.state.value.mfa!=null || holder.state.value.user!=null)password=""} }} } LaunchedEffect(holder) {if(!state.initialized)holder.initialize()} @@ -84,7 +96,7 @@ import org.spaceworks.app.platform.GoogleIdentity Text(copyText(UiStrings.MOBILE_I_VE_SAVED_MY_RECOVERY_CODES)) } BrandError(state.error) - BrandButton(copyText(UiStrings.MOBILE_CONTINUE),{scope.launch {holder.finishMfa()}},!state.busy && acknowledged) + BrandButton(copyText(UiStrings.MOBILE_CONTINUE),{launchAuth {outcome->holder.finishMfaWithOutcome(outcome)}},!state.busy && acknowledged) } state.mfa!=null -> { val mfa=requireNotNull(state.mfa) @@ -111,7 +123,7 @@ import org.spaceworks.app.platform.GoogleIdentity if(!enrollment || state.enrollment!=null) { BrandField(code,{code=if(recoveryEntry)it.take(64) else it.filter {c->c in '0'..'9'}.take(6)},if(recoveryEntry)copyText(UiStrings.MOBILE_RECOVERY_CODE) else copyText(UiStrings.MOBILE_AUTHENTICATION_CODE),Modifier.testTag("mfaCode"),!state.busy,role=if(recoveryEntry)FieldRole.TEXT else FieldRole.CODE) BrandButton(if(state.busy)copyText(UiStrings.MOBILE_VERIFYING) else if(enrollment)copyText(UiStrings.MOBILE_VERIFY_AND_CONTINUE) else copyText(UiStrings.MOBILE_VERIFY),{ - focus.clearFocus();keyboard?.hide();scope.launch {holder.verify(code);code=""} + focus.clearFocus();keyboard?.hide();launchAuth {outcome->holder.verifyWithOutcome(code,outcome);code=""} },!state.busy && if(recoveryEntry)code.trim().length in 6..64 else code.length==6) } if(!enrollment)BrandTextButton(if(recoveryEntry)copyText(UiStrings.MOBILE_USE_AUTHENTICATOR_INSTEAD) else copyText(UiStrings.MOBILE_USE_A_RECOVERY_CODE),{recoveryEntry=!recoveryEntry;code=""},!state.busy) @@ -128,7 +140,7 @@ import org.spaceworks.app.platform.GoogleIdentity } else { state.googleClientId?.let { if(holder.nativeSocialReady && !bundle.demo)GoogleSignInButton(!state.busy && retrySeconds==0L) { - scope.launch {holder.googleSignIn {client,nonce->GoogleIdentity.credential(context.activity() ?: throw AuthMessage(copyText(UiStrings.MOBILE_THIS_SIGN_IN_OPTION_ISN_T_AVAILABLE_ON_THIS_DEVICE)),client,nonce)}} + launchAuth {outcome->holder.googleSignInWithOutcome({client,nonce->GoogleIdentity.credential(context.activity() ?: throw AuthMessage(copyText(UiStrings.MOBILE_THIS_SIGN_IN_OPTION_ISN_T_AVAILABLE_ON_THIS_DEVICE)),client,nonce)},outcome)} } else if(!bundle.demo)Text(copyText(UiStrings.MOBILE_GOOGLE_DEVICE_SIGN_IN_IS_NOT_CONFIGURED_FOR_THIS_BUILD_CONTACT_Y),style=MaterialTheme.typography.bodySmall) if(state.passwordEnabled && holder.nativeSocialReady)Text(copyText(UiStrings.MOBILE_OR_SIGN_IN_WITH_A_PASSWORD),style=MaterialTheme.typography.labelLarge) diff --git a/app/src/main/java/org/spaceworks/app/ui/BrandComponents.kt b/app/src/main/java/org/spaceworks/app/ui/BrandComponents.kt index a834e1b..f3c96fd 100644 --- a/app/src/main/java/org/spaceworks/app/ui/BrandComponents.kt +++ b/app/src/main/java/org/spaceworks/app/ui/BrandComponents.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.unit.dp import org.spaceworks.app.R @Composable fun BrandTextButton(text:String,onClick:()->Unit,enabled:Boolean=true,modifier:Modifier=Modifier) { - TextButton(onClick=onClick,enabled=enabled,modifier=modifier.heightIn(min=48.dp), + TextButton(onClick=onClick,enabled=enabled,shape=MaterialTheme.shapes.small,modifier=modifier.heightIn(min=48.dp), colors=ButtonDefaults.textButtonColors(contentColor=MaterialTheme.colorScheme.onPrimaryContainer)) {Text(text)} } @Composable fun BrandButton(text:String,onClick:()->Unit,enabled:Boolean=true,modifier:Modifier=Modifier) { diff --git a/app/src/main/java/org/spaceworks/app/ui/MemberActionsScreen.kt b/app/src/main/java/org/spaceworks/app/ui/MemberActionsScreen.kt index 3f3add0..264c223 100644 --- a/app/src/main/java/org/spaceworks/app/ui/MemberActionsScreen.kt +++ b/app/src/main/java/org/spaceworks/app/ui/MemberActionsScreen.kt @@ -46,6 +46,7 @@ val LocalMemberActionScope=staticCompositionLocalOfAlertDialog(onDismissRequest={if(!s.busy)confirm=null},title={Text(when(action){"renew"->copyText(UiStrings.MOBILE_RENEW_THIS_LOAN);"cancelBooking"->copyText(UiStrings.MOBILE_CANCEL_THIS_BOOKING);"reschedule"->copyText(UiStrings.MOBILE_CHANGE_THIS_BOOKING);"cancelRegistration"->copyText(UiStrings.MOBILE_CANCEL_THIS_REGISTRATION);else->copyText(UiStrings.MOBILE_SUBMIT_YOUR_REQUEST)})},text={Text(when(action){"renew"->copyText(UiStrings.MOBILE_YOUR_MAKERSPACE_WILL_DECIDE_THE_NEW_DUE_DATE_AND_WHETHER_RENEWAL);"cancelBooking"->copyText(UiStrings.MOBILE_YOUR_MAKERSPACE_S_CANCELLATION_RULES_APPLY_THIS_CANNOT_BE_UNDONE);"reschedule"->copyText(UiStrings.MOBILE_THE_SERVER_WILL_CHECK_THE_NEW_TIME_BEFORE_CHANGING_YOUR_BOOKING);"cancelRegistration"->copyText(UiStrings.MOBILE_YOUR_PLACE_MAY_BE_OFFERED_TO_SOMEONE_ELSE_CANCELLATION_RULES_APP);else->copyText(UiStrings.MOBILE_CHECK_YOUR_ITEMS_AND_QUANTITIES_YOUR_MAKERSPACE_WILL_REVIEW_THE)})},confirmButton={BrandTextButton(copyText(UiStrings.MOBILE_CONFIRM),{confirm=null;work.launch {when(action){"renew"->holder.renew();"cancelBooking"->holder.cancelBooking();"reschedule"->holder.reschedule();"cancelRegistration"->holder.cancelRegistration();else->holder.submitRequest()}}},enabled)},dismissButton={BrandTextButton(when(action){"cancelBooking"->copyText(UiStrings.MOBILE_KEEP_BOOKING);"cancelRegistration"->copyText(UiStrings.MOBILE_KEEP_REGISTRATION);else->copyText(UiStrings.MOBILE_KEEP_REVIEWING)},{confirm=null},!s.busy)})} + confirm?.let {action->AlertDialog(onDismissRequest={if(!s.busy)confirm=null},title={Text(when(action){"renew"->copyText(UiStrings.MOBILE_RENEW_THIS_LOAN);"cancelBooking"->copyText(UiStrings.MOBILE_CANCEL_THIS_BOOKING);"reschedule"->copyText(UiStrings.MOBILE_CHANGE_THIS_BOOKING);"cancelRegistration"->copyText(UiStrings.MOBILE_CANCEL_THIS_REGISTRATION);else->copyText(UiStrings.MOBILE_SUBMIT_YOUR_REQUEST)})},text={Text(when(action){"renew"->copyText(UiStrings.MOBILE_YOUR_MAKERSPACE_WILL_DECIDE_THE_NEW_DUE_DATE_AND_WHETHER_RENEWAL);"cancelBooking"->copyText(UiStrings.MOBILE_YOUR_MAKERSPACE_S_CANCELLATION_RULES_APPLY_THIS_CANNOT_BE_UNDONE);"reschedule"->copyText(UiStrings.MOBILE_THE_SERVER_WILL_CHECK_THE_NEW_TIME_BEFORE_CHANGING_YOUR_BOOKING);"cancelRegistration"->copyText(UiStrings.MOBILE_YOUR_PLACE_MAY_BE_OFFERED_TO_SOMEONE_ELSE_CANCELLATION_RULES_APP);else->copyText(UiStrings.MOBILE_CHECK_YOUR_ITEMS_AND_QUANTITIES_YOUR_MAKERSPACE_WILL_REVIEW_THE)})},confirmButton={BrandTextButton(copyText(UiStrings.MOBILE_CONFIRM),{confirm=null;val ticket=feedback.begin();val before=holder.state.value;work.launch {val recorded=when(action){"renew"->holder.renew();"cancelBooking"->holder.cancelBooking();"reschedule"->holder.reschedule();"cancelRegistration"->holder.cancelRegistration();else->holder.submitRequest()};val after=holder.state.value;feedback.finish(ticket,if(recorded && after.scope==before.scope && after.error==null && before.receipt==null && after.receipt!=null)FeedbackKind.CONFIRM else null)}},enabled)},dismissButton={BrandTextButton(when(action){"cancelBooking"->copyText(UiStrings.MOBILE_KEEP_BOOKING);"cancelRegistration"->copyText(UiStrings.MOBILE_KEEP_REGISTRATION);else->copyText(UiStrings.MOBILE_KEEP_REVIEWING)},{confirm=null},!s.busy)})} Box(Modifier.fillMaxSize(),contentAlignment=androidx.compose.ui.Alignment.TopCenter) { Column(Modifier.widthIn(max=640.dp).fillMaxWidth().imePadding().verticalScroll(scroll).padding(24.dp),verticalArrangement=Arrangement.spacedBy(20.dp)) { BrandTextButton(copyText(UiStrings.MOBILE_BACK),{close()},!s.busy) diff --git a/app/src/main/java/org/spaceworks/app/ui/OnboardingScreen.kt b/app/src/main/java/org/spaceworks/app/ui/OnboardingScreen.kt index 68f4f60..6670754 100644 --- a/app/src/main/java/org/spaceworks/app/ui/OnboardingScreen.kt +++ b/app/src/main/java/org/spaceworks/app/ui/OnboardingScreen.kt @@ -2,10 +2,6 @@ package org.spaceworks.app.ui import org.spaceworks.app.platform.* -import android.database.ContentObserver -import android.os.Handler -import android.os.Looper -import android.provider.Settings import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.Image @@ -21,6 +17,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.* import androidx.compose.ui.unit.dp @@ -28,17 +25,6 @@ import org.spaceworks.app.* import org.spaceworks.app.R import org.spaceworks.app.theme.BrandMotion -@Composable fun rememberReducedMotion():Boolean { - val context=LocalContext.current - fun read()=Settings.Global.getFloat(context.contentResolver,Settings.Global.ANIMATOR_DURATION_SCALE,1f)==0f - var reduced by remember(context){mutableStateOf(read())} - DisposableEffect(context) { - val observer=object:ContentObserver(Handler(Looper.getMainLooper())){override fun onChange(selfChange:Boolean){reduced=read()}} - context.contentResolver.registerContentObserver(Settings.Global.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE),false,observer) - onDispose {context.contentResolver.unregisterContentObserver(observer)} - } - return reduced -} private data class IntroPage(val title:MobileCopyKey,val body:MobileCopyKey,val action:MobileCopyKey) private val introPages=listOf( IntroPage(UiStrings.MOBILE_MAKE_ROOM,UiStrings.MOBILE_FIND_TOOLS_BOOK_A_SPACE_AND_SEE_WHAT_S_NEXT_AT_YOUR_MAKERSPACE,UiStrings.MOBILE_FIND_THE_RIGHT_TOOLS), @@ -49,20 +35,22 @@ private val introPages=listOf( val copyText=rememberMobileText() var step by rememberSaveable {mutableIntStateOf(0)} - val reduced=rememberReducedMotion() + val reduced=LocalReducedMotion.current + val feedback=rememberActionFeedback(Unit) + val slideOffset=with(LocalDensity.current){12.dp.roundToPx()} val easing=CubicBezierEasing(BrandMotion.easing[0],BrandMotion.easing[1],BrandMotion.easing[2],BrandMotion.easing[3]) Box(Modifier.fillMaxSize(),contentAlignment=Alignment.TopCenter) { Column(Modifier.widthIn(max=620.dp).fillMaxWidth().verticalScroll(rememberScrollState()).padding(24.dp),verticalArrangement=Arrangement.spacedBy(24.dp)) { Row(Modifier.fillMaxWidth(),horizontalArrangement=Arrangement.SpaceBetween,verticalAlignment=Alignment.CenterVertically) { // The master logo stays static. Only the surrounding content below transitions. BrandLogo(compact=true) - BrandTextButton(copyText(UiStrings.MOBILE_SKIP_INTRODUCTION),onFinish) + BrandTextButton(copyText(UiStrings.MOBILE_SKIP_INTRODUCTION),{feedback.perform(FeedbackKind.SELECTION);onFinish()}) } val stepDescription=copyText(UiStrings.MOBILE_STEP_VALUE_OF_3,step+1) Text(stepDescription,style=MaterialTheme.typography.labelLarge,modifier=Modifier.semantics {stateDescription=stepDescription}) AnimatedContent(targetState=step,label=copyText(UiStrings.MOBILE_INTRODUCTION_PANEL),transitionSpec={ if(reduced)EnterTransition.None togetherWith ExitTransition.None - else (fadeIn(tween(BrandMotion.EXPAND,easing=easing))+slideInVertically(tween(BrandMotion.EXPAND,easing=easing)){12}) togetherWith fadeOut(tween(BrandMotion.OVERLAY_EXIT,easing=easing)) + else (fadeIn(tween(BrandMotion.EXPAND,easing=easing))+slideInVertically(tween(BrandMotion.EXPAND,easing=easing)){slideOffset}) togetherWith fadeOut(tween(BrandMotion.OVERLAY_EXIT,easing=easing)) }) {index -> val page=introPages[index] Column(verticalArrangement=Arrangement.spacedBy(24.dp)) { @@ -77,8 +65,8 @@ private val introPages=listOf( } } } - BrandButton(if(step==2)copyText(UiStrings.MOBILE_GET_STARTED) else copyText(UiStrings.MOBILE_NEXT),{if(step==2)onFinish()else step++}) - if(step>0)BrandTextButton(copyText(UiStrings.MOBILE_BACK),{step--}) + BrandButton(if(step==2)copyText(UiStrings.MOBILE_GET_STARTED) else copyText(UiStrings.MOBILE_NEXT),{if(step==2){feedback.perform(FeedbackKind.CONFIRM);onFinish()}else{feedback.perform(FeedbackKind.SELECTION);step++}}) + if(step>0)BrandTextButton(copyText(UiStrings.MOBILE_BACK),{feedback.perform(FeedbackKind.SELECTION);step--}) if(step==2)onDemo?.let {BrandTextButton(copyText(UiStrings.MOBILE_EXPLORE_DEMO),it)} } } diff --git a/app/src/test/java/org/spaceworks/app/AuthTest.kt b/app/src/test/java/org/spaceworks/app/AuthTest.kt index 8add9b3..67a1c29 100644 --- a/app/src/test/java/org/spaceworks/app/AuthTest.kt +++ b/app/src/test/java/org/spaceworks/app/AuthTest.kt @@ -49,6 +49,46 @@ private class FakeAuth:AuthGateway { override suspend fun changePassword(current:String,replacement:String){changed=true} } class AuthTest { + @Test fun delayedVerificationCannotEmitFeedbackForReplacementInstallation()=runTest { + val api=FakeAuth();val store=MemoryTokenStore();val holder=AuthStateHolder(api,store) + holder.initialize();holder.signIn("member","pass") + api.verifyGate=CompletableDeferred() + val outcomes=mutableListOf() + val old=launch {holder.verifyWithOutcome("123456",outcomes::add)};runCurrent() + val replacement=TokenPair("new-account-access","new-account-refresh") + store.replace(replacement) + api.verifyGate!!.complete(Unit);old.join() + assertTrue(outcomes.isEmpty());assertEquals(replacement,store.read()) + assertNull(holder.state.value.user) + } + @Test fun acceptedOutcomeBelongsToExactHolderAndInstallationNotLaterAcceptedAccount()=runTest { + val api=FakeAuth();val store=MemoryTokenStore();val holder=AuthStateHolder(api,store) + val outcomes=mutableListOf() + holder.initialize();holder.signInWithOutcome("member","pass",outcomes::add) + assertEquals(AuthOperationKind.PROGRESSED,outcomes.single().kind) + holder.verifyWithOutcome("123456",outcomes::add) + val accepted=outcomes.last() + assertEquals(AuthOperationKind.ACCEPTED,accepted.kind);assertTrue(holder.isCurrentOutcome(accepted)) + val otherHolder=AuthStateHolder(api,store) + assertFalse(otherHolder.isCurrentOutcome(accepted)) + holder.signOut();api.who=api.who.copy(id=2,username="replacement") + holder.signIn("replacement","pass");holder.verifyWithOutcome("123456",outcomes::add) + assertEquals(2,holder.state.value.user!!.id) + assertFalse(holder.isCurrentOutcome(accepted));assertTrue(holder.isCurrentOutcome(outcomes.last())) + } + @Test fun cancelledVerificationNeverEmitsCompletionButFreshOwnedActionCan()=runTest { + val api=FakeAuth();val store=MemoryTokenStore();val holder=AuthStateHolder(api,store) + holder.initialize();holder.signIn("member","pass") + api.verifyGate=CompletableDeferred() + val outcomes=mutableListOf() + val cancelled=launch {holder.verifyWithOutcome("123456",outcomes::add)};runCurrent() + cancelled.cancelAndJoin() + assertTrue(outcomes.isEmpty());assertFalse(holder.state.value.busy);assertNull(store.read()) + api.verifyGate=null + holder.verifyWithOutcome("123456",outcomes::add) + assertEquals(AuthOperationKind.ACCEPTED,outcomes.single().kind) + assertTrue(holder.isCurrentOutcome(outcomes.single())) + } @Test fun delayedLogoutInvalidatesPrivateFlowsBeforeAwaitingServerRevocation()=runTest { val api=FakeAuth();val store=MemoryTokenStore();var privateScopeAccessible=true val holder=AuthStateHolder(api,store,onSessionEnded={privateScopeAccessible=false}) diff --git a/app/src/test/java/org/spaceworks/app/BrandTest.kt b/app/src/test/java/org/spaceworks/app/BrandTest.kt index 928edb2..df985c0 100644 --- a/app/src/test/java/org/spaceworks/app/BrandTest.kt +++ b/app/src/test/java/org/spaceworks/app/BrandTest.kt @@ -9,7 +9,21 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import org.spaceworks.app.theme.LightScheme import org.spaceworks.app.theme.DarkScheme +import org.spaceworks.app.theme.BrandTypography +import org.spaceworks.app.theme.BrandFontFamily +import androidx.compose.material3.Typography class BrandTest { + @Test fun allMaterialRolesUseGilroyWithoutChangingNativeTypeScale() { + fun roles(t:Typography)=listOf(t.displayLarge,t.displayMedium,t.displaySmall, + t.headlineLarge,t.headlineMedium,t.headlineSmall,t.titleLarge,t.titleMedium,t.titleSmall, + t.bodyLarge,t.bodyMedium,t.bodySmall,t.labelLarge,t.labelMedium,t.labelSmall) + roles(BrandTypography).zip(roles(Typography())).forEach {(brand,native) -> + assertEquals(BrandFontFamily,brand.fontFamily) + assertEquals(native.fontSize,brand.fontSize) + assertEquals(native.lineHeight,brand.lineHeight) + assertEquals(native.letterSpacing,brand.letterSpacing) + } + } @Test fun themeMatchesPinnedSource() { assertEquals(0xFFFFF4E8.toInt(),BrandTokens.Light.bg.toArgb()) assertEquals(0xFF142C2A.toInt(),BrandTokens.Dark.bg.toArgb()) diff --git a/app/src/test/java/org/spaceworks/app/platform/OutcomeFeedbackTest.kt b/app/src/test/java/org/spaceworks/app/platform/OutcomeFeedbackTest.kt new file mode 100644 index 0000000..44978ed --- /dev/null +++ b/app/src/test/java/org/spaceworks/app/platform/OutcomeFeedbackTest.kt @@ -0,0 +1,48 @@ +package org.spaceworks.app.platform + +import org.junit.Assert.* +import org.junit.Test +import org.spaceworks.app.auth.AuthState +import org.spaceworks.app.auth.AuthOperationKind +import org.spaceworks.app.auth.authOperationKind +import org.spaceworks.generated.model.AuthUserPayload +import org.spaceworks.generated.model.MFAChallengeResponse + +class OutcomeFeedbackTest { + @Test fun replacementActionOwnerOrDisposedScreenCannotReplayOldFeedback() { + val gate=FeedbackTicketGate() + val first=gate.begin("account-a") + val second=gate.begin("account-a") + assertFalse(gate.finish(first,"account-a",true)) + assertTrue(gate.finish(second,"account-a",true)) + assertFalse(gate.finish(second,"account-a",true)) + val replaced=gate.begin("account-a") + assertFalse(gate.finish(replaced,"account-b",true)) + val disposed=gate.begin("account-a") + gate.cancel() + assertFalse(gate.finish(disposed,"account-a",true)) + } + @Test fun backgroundOrDisabledCompletionIsConsumedRatherThanReplayedOnResume() { + val gate=FeedbackTicketGate() + val ticket=gate.begin("scope") + assertFalse(gate.finish(ticket,"scope",false)) + assertFalse(gate.finish(ticket,"scope",true)) + assertTrue(gate.finish(gate.begin("scope"),"scope",true)) + } + @Test fun mfaAndRequiredPasswordAreProgressionRatherThanCompletedAuthentication() { + val before=AuthState(initialized=true) + val user=AuthUserPayload(1,"member","member@example.invalid","Member","",true,"member",false,false,emptyList()) + assertEquals(AuthOperationKind.PROGRESSED,authOperationKind(before,before.copy(mfa=MFAChallengeResponse(true,false,"challenge",300)),false)) + assertEquals(AuthOperationKind.PROGRESSED,authOperationKind(before,before.copy(user=user.copy(mustChangePassword=true)),true)) + assertNull(authOperationKind(before,before.copy(user=user),false)) + assertEquals(AuthOperationKind.ACCEPTED,authOperationKind(before,before.copy(user=user),true)) + assertNull(authOperationKind(before.copy(user=user),before.copy(user=user),true)) + } + @Test fun oldErrorsAndBusyOrUnchangedReadsDoNotCreateNewOutcomeFeedback() { + val before=AuthState(initialized=true,error="Check your sign-in details") + assertNull(authOperationKind(before,before,false)) + assertNull(authOperationKind(before,before.copy(busy=true,error="New error"),false)) + assertEquals(AuthOperationKind.REJECTED,authOperationKind(before,before.copy(error="Verification expired"),false)) + assertNull(authOperationKind(AuthState(),AuthState(initialized=true),false)) + } +} diff --git a/app/src/test/java/org/spaceworks/app/platform/PaymentBrandAppearanceTest.kt b/app/src/test/java/org/spaceworks/app/platform/PaymentBrandAppearanceTest.kt new file mode 100644 index 0000000..c15c156 --- /dev/null +++ b/app/src/test/java/org/spaceworks/app/platform/PaymentBrandAppearanceTest.kt @@ -0,0 +1,25 @@ +package org.spaceworks.app.platform + +import com.stripe.android.paymentsheet.PaymentSheet +import org.junit.Assert.* +import org.junit.Test + +class PaymentBrandAppearanceTest { + @Test fun actualSdkConfigurationKeepsExactBrandSurfacesInBothNativeModes() { + val appearance=stripeBrandAppearance() + assertEquals(PaymentSheet.Colors( + primary=0xFF006E63.toInt(),surface=0xFFFFF4E8.toInt(),component=0xFFFFFFFF.toInt(), + componentBorder=0xFF828A83.toInt(),componentDivider=0xFFD5D0C6.toInt(), + onComponent=0xFF142C2A.toInt(),onSurface=0xFF142C2A.toInt(), + subtitle=0xFF5B6863.toInt(),placeholderText=0xFF5B6863.toInt(), + appBarIcon=0xFF142C2A.toInt(),error=0xFFB91C1C.toInt()),appearance.getColors(false)) + assertEquals(PaymentSheet.Colors( + primary=0xFFABC8BC.toInt(),surface=0xFF142C2A.toInt(),component=0xFF1F433F.toInt(), + componentBorder=0xFF93A198.toInt(),componentDivider=0xFF4D6D67.toInt(), + onComponent=0xFFFFF4E8.toInt(),onSurface=0xFFFFF4E8.toInt(), + subtitle=0xFFC7C4BA.toInt(),placeholderText=0xFFC7C4BA.toInt(), + appBarIcon=0xFFFFF4E8.toInt(),error=0xFFFCA5A5.toInt()),appearance.getColors(true)) + assertNotEquals(appearance.getColors(false),appearance.getColors(true)) + assertEquals("#006E63",razorpayBrandColor()) + } +} diff --git a/branding/brand.json b/branding/brand.json index 492a5d7..01a7fd1 100644 --- a/branding/brand.json +++ b/branding/brand.json @@ -13,5 +13,9 @@ "logo_symbol_one_colour_ink": "design/logo/symbol-one-colour-ink.svg", "logo_stacked": "design/logo/lockup-stacked.svg", "logo_stacked_reversed": "design/logo/lockup-stacked-reversed.svg", - "logo_stacked_one_colour_ink": "design/logo/lockup-stacked-one-colour-ink.svg" + "logo_stacked_one_colour_ink": "design/logo/lockup-stacked-one-colour-ink.svg", + "font_regular": "design/fonts/Gilroy-Regular.woff", + "font_medium": "design/fonts/Gilroy-Medium.woff", + "font_semibold": "design/fonts/Gilroy-SemiBold.woff", + "font_bold": "design/fonts/Gilroy-Bold.woff" } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f57726f..709cea5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,43 @@ # Phase notes +## C1 — repository hardening and validated handoff (2026-10-18) + +Tested source [`3a400c7`](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/commit/3a400c7bcf7c16164393ea493983a4bd173a710b) pins the existing CI actions to full upstream commits, disables checkout credential persistence and verifies the unchanged Gradle 8.13 distribution checksum. Existing read-only workflow permissions, fork-cache protections, native commands, profiles and timeouts remain intact. Narrow private-configuration ignores, contribution/security/dependency guidance and supplied-font provenance document the supported handoff; the exact existing Unbounded OFL is now packaged and verified in each APK. No SDK/toolchain version, app behavior or backend contract changes are included, and a fully locked, independently verified transitive dependency graph remains follow-up work. + +[Canonical CI 39105105315](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/actions/runs/39105105315) and [full ten-profile CI 39105109733](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/actions/runs/39105109733) completed successfully at that exact source: 538 Debug + 506 Release = 1,044 unit executions and 59 UI cases on each of ten profiles = 590, with zero failures, errors or skips. All 210 selected original full-OS screenshots were actually reviewed. Font resolution, native feedback/settings, observed profile and keyboard geometry, configuration-backed 3.2× PDF text metrics/word boundaries, 16 KB packaging and exact bundled OFL checks passed. README validation now points to this source; its six-image gallery and asset provenance remain historical `11cc205` evidence, unchanged. + +Parity checklist: member, account/security/global-no-membership, staff, introduction/replay/guide, light/dark and scalable phone/tablet flows retain the accepted behavior. This Android handoff does not establish separate iOS acceptance. Live backend/provider operation, owner signing/store distribution, supplied-font embedding rights and physical tactile/runtime-device checks remain deployment gates. The documentation-only handoff preserves the tested app/build/test/resource tree; the current existing-PR CI guards still apply before the authorized final merge. + +## B3 — native motion and owned tactile feedback (2026-10-18) + +Actual a523 compact 3.2× PDF review found a complete Share document button whose default horizontal padding still forced the last letter of document onto a separate line. Only the existing Open/Save/Share outlined document actions now use 16 dp horizontal content padding, retaining 8 dp vertical padding, fully scaled 14 sp labels, 10 dp corners, minimum touch targets and the native scroll. The same existing PDF case checks actual text-layout word boundaries and records line ends as well as scale/metrics before its unchanged full-action, file-cleanup and no-SDK assertions. Fresh exact-source native and visual validation remains required; no label is shortened or truncated. + +The first configuration-backed e51 phone run reached a real demo-preview layout failure before PDF: at 3.2× the fixed AlertDialog title and Keep reviewing slot consumed its compact height, leaving no visible outcome viewport. The demo-only preview now puts its fully scaled native title, unchanged no-provider/no-money disclaimer, six outcomes and Keep reviewing in one bounded native dialog scroll. The existing case verifies complete reachability of every choice and cancel control before its unchanged server-confirmed payment/PDF outcome; live checkout and callback authority are unchanged. Fresh exact-source validation is required; this is not a font-scale cap or a relaxed action-bound assertion. + +Fresh c8 device checks passed the font/native-feedback cases, but actual PDF images exposed a coverage gap: the outer composition's synthetic 3.2× density did not configure the Dialog's separate native view, so those images proved only the device-profile scale. The existing PDF case now uses the supported test configuration override and a real scoped ComposeView host, without changing app code or OS settings. It verifies the actual dialog text owner's configuration, text-layout density and enlarged character-box/line metrics for all three actions before the existing full-viewport checks and captures. Configuration-backed native-dialog 3.2× proof remains pending its fresh exact-source device run; all 59 cases and outcomes remain. + +The first fresh ten-profile run exposed a new test-only font measurement mismatch: on the large tablet all 57 prior UI cases and the native feedback case passed, while `measureText` returned the integral ceiling in Android’s Paint implementation (13.0) for a 12.8 fractional glyph advance. The font registration test now uses the public fractional `getRunAdvance` API with the same subpixel/linear flags, all four faces, three sizes, Compose/XML resolution, system bold-text check and unchanged .03 tolerance. App rendering and supplied font tables are unchanged. Fresh exact-source device validation is required after this correction. + +Actual compact 3.2× full-OS review also found leading glyphs clipped inside default capsule text buttons and long guide words fragmented. Text/document controls now use the existing native brand 10 dp shape; expanded guide paragraphs use fully scaled native bodyMedium and the auxiliary reset notice bodySmall. Existing journeys add word-boundary checks and a full sample-action capture for manual corner inspection. The wide-tablet PDF frame showed a partially offscreen Save control within its existing scrollable dialog, not an established inaccessible action: the existing case now uses 3.2× text, reveals and verifies complete Save/Share/Done action frames in the actual document viewport and captures each, without changing the PDF layout or private-file behavior. All 59 cases and prior outcomes remain. + +Onboarding retains its static supplied logo while content uses the existing short brand transitions and density-aware offsets. App guide disclosures animate their existing single content body; the shared production theme observes Android's animation-duration setting and removes custom motion when it is off. Component hosts use the same production theme. + +Explicit onboarding progression, owned authentication progression/acceptance or rejection, successful QR resolution and newly server-confirmed member/staff receipts receive settings-aware native feedback. Authentication feedback carries a nonsecret completion stamp from the existing guarded operation; canceled or replaced operations cannot borrow a later account's accepted state. Screen disposal, owner replacement, background lifecycle, disabled view feedback and duplicate completions suppress dispatch. No polling, session restoration, payment SDK submission, generic navigation or every-button vibration is added. Android controls the actual tactile effect and respects system settings; no custom waveform, ignore-setting flag or vibration permission is introduced. + +Regression coverage includes real delayed/canceled authentication and replacement-account ownership, feedback consumption and native view dispatch/settings gates. Existing large-text guide journeys also exercise animation-off expansion with a single accessible body. Local and fresh ten-profile runtime/visual validation are recorded with the final source gate; native dispatch checks do not establish physical tactile quality on emulator hardware. + +## B2 — native payment SDK brand appearance (2026-10-18) + +The pinned Stripe 22.8.1 public Appearance API now receives the semantic light/dark brand colors, a table-preserving Gilroy XML family with explicit weights, 10 dp controls and branded primary/success colors. SDK sizing, accessibility, native layout and system appearance selection remain unchanged. Razorpay Android Standard Checkout receives its documented brand accent only; unsupported font/dark-palette options are not invented, and the merchant's provider logo is retained. No payment intent, authority, callback, retry or server-status behavior changes. + +Actual SDK color configuration has a JVM contract check; native font-resolution coverage now checks all four weights through the XML family used by Stripe. The no-monospace audit found no app-owned monospaced text in main/Debug sources, so identifiers and secret values are unchanged. Local lint, 531 Debug / 499 Release JVM cases (zero failures/errors/skips), all 58 native-case compilation, APK/AAB assembly and 16 KB/native-feature packaging checks passed. Fresh device font-family proof remains pending; live payment/provider visual proof still requires the owner's backend/provider setup. + +## B1 — complete native Material brand typography (2026-10-18) + +The owner requested full Space Works brand fidelity and the latest stable native Material theme. The exact existing BOM resolves Material 3 1.4.0, confirmed as Google's current stable release; the tested toolchain and native navigation bars/rails are retained. Supplied Gilroy Regular/Medium/Semibold/Bold now replace default Roboto across all 15 Material roles in the app and restored-checkout host. Native type sizes, font scaling, system bold-text behavior, touch targets, colors, supplied logos and short Unbounded accents remain intact. Earlier phase notes about pending Gilroy clearance describe their historical checkpoint and are superseded by this owner-authorized supplied-asset integration. + +Font conversion preserves every sfnt table except the container checksum and records source/output hashes. Python tests verify actual supplied table/metric preservation, deterministic four-face generation and failure safety; JVM checks cover all Material roles and native instrumentation verifies the registered fonts' distinct glyph metrics, scaling and bold-text resolution. Component journeys render the same production theme. Local lint, all 530 Debug / 498 Release JVM cases (zero failures/errors/skips), Debug/Release APK, Release AAB and all 58 native-case compilation passed. The three new real-font Python cases and existing ten brand-link cases passed; both APKs and the AAB contain byte-exact generated font resources and both APKs pass 16 KB/native-feature packaging checks. Fresh ten-profile device/visual evidence is pending; no new runtime SDK or major Compose/AGP migration is included. + ## A0 Base — foundation (2026-10-16) Scaffolded Kotlin/Compose Android app with min SDK 26 and compile/target 36, generated semantic light/dark theme, `brand.json` identity/name/icon/server configuration, pinned official design submodule, owner contract snapshot and a build-time generated Retrofit/kotlinx.serialization client. Networking includes deployment validation, auth/makerspace headers, no cookies or browser headers, no redirect credential forwarding, structured errors/Retry-After, safe next-page URL resolution, and serialized refresh rotation with atomic in-memory pair replacement. No signed-in session can be created in A0; Keystore persistence/real sign-in belong to A1. diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md new file mode 100644 index 0000000..9585687 --- /dev/null +++ b/docs/DEPENDENCIES.md @@ -0,0 +1,103 @@ +# Dependency and build provenance + +This inventory describes the declarations in `build.gradle.kts`, +`app/build.gradle.kts` and the Gradle wrapper. It is not an SBOM, a complete list of +transitive licenses, a vulnerability clearance or a grant to redistribute the app +and supplied assets. Preserve the currently tested versions until a reviewed +maintenance phase validates a replacement. + +## Tools and repositories + +| Input | Declared version/source | +|---|---| +| Gradle | 8.13 binary distribution through the checked-in wrapper | +| Android Gradle Plugin | 8.13.0 | +| Kotlin Android / Compose / serialization plugins | 2.2.21 | +| OpenAPI Generator | 7.26.0; generated Kotlin Retrofit/serialization client | +| Google Services plugin | 4.5.0; applied only when private configuration exists | +| Build environment | JDK 21, Python 3, Android SDK 36; JVM target 17 | +| Dependency repositories | Google Maven and Maven Central; plugin portal for plugins | + +The wrapper's `distributionSha256Sum` is +`20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78`, verified against +the [official 8.13 binary ZIP checksum](https://services.gradle.org/distributions/gradle-8.13-bin.zip.sha256) +and [Gradle checksum reference](https://gradle.org/release-checksums/). The wrapper +JAR is unchanged; CI's Gradle setup action also validates wrapper JARs. + +## Direct SDK and test inputs + +| Group | Exact declarations | +|---|---| +| Compose | BOM 2025.10.01; Material 3, Material icons core, UI test JUnit4/test manifest | +| AndroidX | Activity 1.11.0; Navigation 2.9.5; Lifecycle 2.9.4; Credentials and Play Services adapter 1.6.0 | +| Google identity / integrity | GoogleID 1.1.1; Play Integrity 1.6.0 | +| Firebase | BOM 34.4.0; Messaging | +| Camera / images / scans | CameraX core/camera2/lifecycle/view 1.5.3; ExifInterface 1.4.2; ML Kit barcode scanning 17.3.0 | +| Kotlin libraries | Coroutines Android/test 1.10.2; serialization JSON 1.9.0 | +| HTTP / serialization | Retrofit and serialization converter 2.11.0; OkHttp / MockWebServer 4.12.0 | +| Payments | Stripe Android 22.8.1; Razorpay Checkout 1.6.41; standard-core strictly 1.7.20 | +| Tests | JUnit 4.13.2; AndroidX test extension JUnit 1.3.0; Compose test modules use the same BOM | + +The generator consumes this repository's authoritative OpenAPI input and reviewed +operation filter/templates. Providers retain their supported native behavior; +adding an SDK does not supply backend authority or deployment configuration. + +Review the resolved POM/module metadata and licenses at the actual artifact +sources, not just this table: [Google Maven](https://maven.google.com/), +[Maven Central](https://central.sonatype.com/), +[AndroidX](https://android.googlesource.com/platform/frameworks/support/), +[Kotlin libraries](https://github.com/Kotlin), +[Square libraries](https://github.com/square), +[Firebase Android](https://github.com/firebase/firebase-android-sdk), +[Stripe Android](https://github.com/stripe/stripe-android), and +[Razorpay Android integration](https://razorpay.com/docs/payments/payment-gateway/android-integration/standard/). +Their licenses and applicable provider terms remain independent of the app's code +and brand assets. No root application-code license is declared in this repository; +do not infer an MIT/Apache grant from an individual dependency or the Design repo. + +## Fonts and assets + +Gilroy uses the owner-supplied four faces and explicit native weight mapping; retain +[its conversion and license distinction](licenses/Gilroy-native-fonts.md). Separate +owner confirmation of app-embedding/distribution rights remains a release input; +the old free Light/ExtraBold EULA does not license those four supplied faces. +Unbounded is limited to short accent copy. Its existing copyright and full OFL +text are copied unchanged into `app/src/main/assets/licenses/OFL-Unbounded.txt`; +the packaging gate requires those exact source bytes in each APK. See +[font provenance](licenses/FONT-PROVENANCE.md) and [the full OFL](licenses/OFL-Unbounded.txt). +Logo masters stay pinned to Design, and README screenshot provenance stays bound +to its recorded tested app commit. Do not rewrite third-party notices or invent +rights for replacement assets. + +## Immutable CI action inputs + +These are the original major refs resolved through each upstream repository's Git +API on 18 October 2026, peeling annotated tags to commits. No action major was +upgraded. The workflow pins the full commits; review upstream changes deliberately +before replacing them. + +| Action | Original ref | Pinned upstream commit | +|---|---|---| +| actions/checkout | v4 | [11d5960a326750d5838078e36cf38b85af677262](https://github.com/actions/checkout/commit/11d5960a326750d5838078e36cf38b85af677262) | +| actions/setup-java | v4 | [cf277c60eb25467037889841efdb72551f06f6c3](https://github.com/actions/setup-java/commit/cf277c60eb25467037889841efdb72551f06f6c3) | +| android-actions/setup-android | v3 | [9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407](https://github.com/android-actions/setup-android/commit/9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407) | +| gradle/actions/setup-gradle | v4 (v4.4.3) | [ed408507eac070d1f99cc633dbcf757c94c7933a](https://github.com/gradle/actions/commit/ed408507eac070d1f99cc633dbcf757c94c7933a) | +| actions/upload-artifact | v4 | [ea165f8d65b6e75b540449e92b4886f43607fa02](https://github.com/actions/upload-artifact/commit/ea165f8d65b6e75b540449e92b4886f43607fa02) | +| reactivecircus/android-emulator-runner | v2 | [a421e43855164a8197daf9d8d40fe71c6996bb0d](https://github.com/ReactiveCircus/android-emulator-runner/commit/a421e43855164a8197daf9d8d40fe71c6996bb0d) | + +## Maintenance and remaining reproducibility work + +Direct versions/BOMs are fixed, but the project does not yet claim a fully locked, +independently checksum-reviewed transitive graph or byte-identical APKs across +different host environments. A finite follow-up must record the current resolved +Debug/Release/test graph, review exact metadata/artifact provenance, then enable +supported Gradle dependency locking and strict verification without silently +changing that graph. Do not treat generated bootstrap checksums as independent +trust evidence or bypass a checksum failure. + +For an approved update, record the original and proposed graphs, upstream release +and license information, changed native libraries/permissions, and meaningful +tests. Re-run generator/catalog checks, lint, both unit variants, package/font +checks and the appropriate exact-source native/visual matrix. Preserve signed +release owner checks separately. Use phase commits on `dev` and the existing +authorized PR; do not add automatic dependency-update PRs under this delivery policy. diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 443b91d..90ca0b3 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -1,6 +1,6 @@ # Remaining mobile implementation plan -Owner extension (2026-10-16): complete the apps in tested phases on `dev`, including adaptive phone and tablet layouts. Push phase commits under Shaan's configured GitHub identity with Codex attribution. Intermediate PR/merge waits are replaced by green phase CI; the final PR is prepared after the batch. The supplied PRD remains unchanged. +Owner extension (2026-10-16): complete the apps in tested phases on `dev`, including adaptive phone and tablet layouts. Push phase commits under Shaan's configured GitHub identity with Codex attribution. Intermediate merge waits are replaced by green phase CI. The latest owner delivery update authorizes existing [PR #4](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/4): consolidate/test/push `dev`, complete exact-source device and visual gates, then merge only its reviewed head into `main`, retaining `dev` and phase commits. Do not create other pull requests or feature branches. The supplied PRD remains unchanged. 1. **A1 identity and secure sessions:** generated challenge/password login/MFA/config/me/logout; Android Keystore atomic token storage; single-flight refresh; selected membership, foreground identity refresh; native attestation where configured. Inspect contract/provider configuration before exposing social methods. A missing native provider/version/authority contract becomes a precise backend-request, never fabricated behavior. 2. **A2 member read:** implement generated inventory, requests/loans, bookings/events, dashboard/card and inbox read flows using authoritative data and pagination. diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index b94ef81..0f3ae6e 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -2,7 +2,8 @@ Prepared 16 October 2026. This procedure is not a claim of store submission. Phase implementation and passing checks are recorded in `docs/CHANGELOG.md`. The owner requested phones/tablets and -deferred backend setup; work stays on `dev` until final review of the `dev` to `main` pull request. +deferred backend setup. The owner now requires all mobile work consolidated on `dev`, exact-source +checks and artifact/visual review, then a guarded merge of only the reviewed head of existing [PR #4](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/4) into `main`. Retain `dev` and phase commits; do not create other pull requests or feature branches. ## Reproduce the app @@ -21,6 +22,28 @@ The unsigned Release APK is under `app/build/outputs/apk/release/` and the unsig `app/build/outputs/bundle/release/`; these artifacts prove compilation/packaging, not Play distribution readiness. +## Repository and supply-chain handoff + +Use the checked-in Gradle 8.13 distribution checksum and immutable upstream action +commits recorded in [dependency provenance](DEPENDENCIES.md). Checkout credentials +are not persisted, CI permissions remain read-only and no publishing/signing job +is configured. Changes to these inputs require new exact-head CI evidence; the +historical app proof linked in README is not relabelled as a new-source test run. + +Review current GitHub main protection, private reporting and secret-scanning/push +protection settings with the owner. Repository policy files do not enable those +settings. Follow [SECURITY.md](../SECURITY.md) for private reporting and +[CONTRIBUTING.md](../CONTRIBUTING.md) for maintenance. Preserve `dev`, phase history +and the existing authorized PR #4; do not introduce automatic update PRs. + +Retain SDK/transitive license and provider-term review as a distribution input, +including separate owner confirmation of supplied Gilroy app-embedding rights. +The current packaged Unbounded font includes the unchanged existing copyright +and full OFL text under `assets/licenses/OFL-Unbounded.txt`; the APK packaging +gate verifies those exact source bytes. This does not grant a license to the +application code or to unrelated supplied assets. Signing, backend/provider, +privacy/legal and store checks below remain separate owner gates. + CI additionally validates every arm64/x86_64 native library's LOAD segments and runs the Android SDK's `zipalign -c -P 16 4` on debug and release APKs. Evidence is in `validation/native-packaging.json`. This verifies binary/ZIP alignment; a 16 KB runtime device @@ -164,9 +187,11 @@ Record distribution evidence after owner provisioning; do not put credentials in | Evidence | Value | |---|---| -| Accepted app / Design commits | Exact tested source SHA and pinned Design input are recorded in the final [dev → main PR](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/2) and matching CI artifacts | -| Full device-matrix CI run | The final PR validation section links the accepted ten-profile run and reviewed artifacts; check that run before acceptance | +| Accepted app / Design commits | Record the exact tested `dev` SHA, pinned Design input and reviewed PR #4 merge into `main` in the final handoff, with matching CI artifacts; retain `dev` and phase commits | +| Full device-matrix CI run | The final handoff must link successful exact-source dev-push and ten-profile runs, observed profile evidence and actually reviewed full-OS images; fresh brand/motion validation remains required | | Signed bundle / versionCode / certificate fingerprint | Owner signing environment required | | Real-device provider test record | Owner backend/configuration required | | Whole-account deletion and web resource verification | Updated owner contract/site required | | Play test-track build / tester acceptance | Owner Play Console access required | + +[PR #2](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/2) records the historical A8 checkpoint. It does not substitute for final proof of later brand, SDK appearance, motion or accessibility corrections. diff --git a/docs/STORE-LISTING.md b/docs/STORE-LISTING.md index f7661ec..9b9a805 100644 --- a/docs/STORE-LISTING.md +++ b/docs/STORE-LISTING.md @@ -65,7 +65,7 @@ final submitted build against the owner's supported backend with a synthetic rev Retain original dimensions and current console device slots; never stretch a phone image into a tablet image. Keep recovery codes, verification codes and QR payloads out of store assets; do not weaken secure-window protections to capture them. Optional staff images must show a -verified authorized staff deployment. The final PR links the accepted layout-proof artifacts. +verified authorized staff deployment. The final release handoff links the exact tested commit, successful CI runs and actually reviewed layout-proof artifacts. Deliver only through the owner-authorized existing [PR #4](https://github.com/SpaceWorks-HQ/SpaceWorks-Android/pull/4) after those gates pass; retain `dev` and phase commits, and create no other pull requests or feature branches. ## Reviewer access and review notes diff --git a/docs/WHITE-LABEL.md b/docs/WHITE-LABEL.md index dae1832..ccdb01d 100644 --- a/docs/WHITE-LABEL.md +++ b/docs/WHITE-LABEL.md @@ -8,8 +8,14 @@ 6. Create your own Firebase project and Android app matching the application ID. Enable/link Play Integrity to your own Play application, register the actual signing certificates and configure the backend app allowlist. Inject runtime Firebase configuration privately; token registration is gated and must not start from onboarding or demo. APNs applies to the companion iOS build and needs your own Apple account and key. 7. Complete privacy policy/Data safety details, reviewer demo account, closed testing and signing with your own accounts in A7/A8. Build a signed AAB (`bundleRelease`) after release signing is configured and publish through your Play Console. -No secrets, signing keys, `.p8` files, Firebase config or test credentials are committed. Ordinary interface text uses the platform font with font scaling. Gilroy app-embedding rights must be cleared before embedding. Debug APKs and unsigned CI release outputs are development/compile evidence. See [RELEASE-READINESS.md](RELEASE-READINESS.md) for the exact checks, owner inputs, privacy review and distribution evidence. The owner's batch workflow permits tested phases on `dev` before the final reviewed merge. +No secrets, signing keys, `.p8` files, Firebase config or test credentials are committed. Ordinary interface text uses the owner-supplied Gilroy Regular, Medium, Semibold and Bold faces with native Material typography and system font scaling. The latest owner instruction authorizes these provided assets; keep their provenance and use them under the applicable Gilroy license. Debug APKs and unsigned CI release outputs are development/compile evidence. See [RELEASE-READINESS.md](RELEASE-READINESS.md) for the exact checks, owner inputs, privacy review and distribution evidence. The owner's batch workflow permits tested phases on `dev` before the final reviewed merge. -Auth/intro branding also reads `logo_symbol`, `logo_symbol_reversed`, `logo_symbol_one_colour_ink` and the equivalent `logo_stacked` paths from `brand.json`. Supply native-vector-compatible SVG masters with their original view boxes; keep the dedicated symbol/stacked clear space and do not animate/recolor/tint the logo. Mobile motion comes from this app repository's `tokens/mobile-motion.json` and UX guidance from `docs/mobile-auth-ux-spec.md`; system animation-off settings suppress custom movement/opacity transitions. The existing Design submodule supplies brand assets and semantic colors; mobile-specific files are maintained in the app repositories. The default open-source Unbounded accent font and its conversion provenance/licence are in `docs/licenses/`; normal text uses the system font. +Auth/intro branding also reads `logo_symbol`, `logo_symbol_reversed`, `logo_symbol_one_colour_ink` and the equivalent `logo_stacked` paths from `brand.json`. Supply native-vector-compatible SVG masters with their original view boxes; keep the dedicated symbol/stacked clear space and do not animate/recolor/tint the logo. Mobile motion comes from this app repository's `tokens/mobile-motion.json` and UX guidance from `docs/mobile-auth-ux-spec.md`; system animation-off settings suppress custom movement/opacity transitions. The existing Design submodule supplies brand assets and semantic colors; mobile-specific files are maintained in the app repositories. The default open-source Unbounded accent font and its conversion provenance/licence are in `docs/licenses/`; normal text uses the supplied Gilroy family. The `font_regular`, `font_medium`, `font_semibold` and `font_bold` paths select the four WOFF1 inputs; `scripts/brand_fonts.py` losslessly repackages their sfnt tables into native Android TTF resources during `prepareBrand`. See [font provenance](licenses/Gilroy-native-fonts.md). Native Google acquisition uses Credential Manager 1.6.0 and the compiler-compatible GoogleID 1.1.1. Register your own package/signing fingerprints and server OAuth web client audience, and publish the documented `social_auth.google` configuration. Do not substitute an Android OAuth client ID for the server web audience. Missing native attestation/session contract still prevents production sign-in. The small Google G is Google's unchanged official sign-in asset ([source](https://developers.google.com/static/identity/images/g-logo.png), [branding requirements](https://developers.google.com/identity/branding-guidelines)); provider styling is intentionally independent of the app palette. No client secret is bundled. DEBUG preview providers never call Google/Apple/SSO/SMS SDKs or your backend; release omits their implementation and Explore demo entry. + +Payment SDK styling uses their documented native appearance interfaces. Stripe receives both semantic brand palettes, 10 dp control corners, standard SDK text sizing, and a native Gilroy XML family with explicit 400/500/600/700 weights. Stripe retains its native layout, accessible controls and system light/dark selection. Razorpay Android Standard Checkout receives only the documented `theme.color` accent; merchant identity/logo remain deployment/provider supplied. Its current Android API does not document a custom font or complete dark palette. The app does not inject unsupported options or restyle provider verification screens. These appearance settings do not confirm payment: existing server refetch and callback ownership checks remain authoritative. + +Supported provider appearance references: [Stripe Android Appearance API](https://docs.stripe.com/elements/appearance-api/mobile?platform=android), [Razorpay Android Standard integration options](https://razorpay.com/docs/payments/payment-gateway/android-integration/standard/integration-steps/). + +Native motion uses the existing mobile motion tokens and observes Android's animation-duration setting through the shared production theme. The supplied logo stays static. Onboarding panel transitions and guide disclosures suppress custom motion when system animation is off. Outcome feedback uses the one-argument `View.performHapticFeedback` API only for explicit progression, guarded auth outcomes and fresh server-recorded member/staff actions. Lifecycle, current owner and duplicate-completion gates suppress stale or background effects; Android retains control of touch-feedback settings and hardware support. No vibration permission or setting-bypass flags are required. diff --git a/docs/images/PROVENANCE.json b/docs/images/PROVENANCE.json new file mode 100644 index 0000000..bb155d0 --- /dev/null +++ b/docs/images/PROVENANCE.json @@ -0,0 +1,116 @@ +{ + "schema_version": 1, + "tested_app_sha": "11cc205c39294baeb3116038a5eb525519813eab", + "assets": [ + { + "destination": "docs/images/brand/space-works-light.png", + "sha256": "63ef979f162a89b1526a908683781b434e3db92b1795bee6d5137e788b44dc6a", + "type": "logo", + "source": { + "repository": "design", + "commit": "fd28f825086a2cac7fa297881f1860b9d332db69", + "path": "logo/lockup-horizontal.png" + } + }, + { + "destination": "docs/images/brand/space-works-dark.png", + "sha256": "d83768eddb190d9cbed7afd7f22b79ad5e3ff53ef4ec6cf3590545fdc3f454b3", + "type": "logo", + "source": { + "repository": "design", + "commit": "fd28f825086a2cac7fa297881f1860b9d332db69", + "path": "logo/lockup-horizontal-reversed.png" + } + }, + { + "destination": "docs/images/screenshots/introduction.png", + "sha256": "ad48fcbe6a66d1954144870e0b51dbd0d41c1eb93aba37bbe0332dbe5016a025", + "type": "screenshot", + "source": { + "source_sha": "11cc205c39294baeb3116038a5eb525519813eab", + "run": 39081400622, + "artifact_id": 12119480990, + "profile": "phone", + "test": "OnboardingAuthTest", + "prefix": "ux-introduction-first", + "artifact_path": "validation/phone/screenshots/ux-introduction-first-screen.png", + "review_sha256": "49b29adb90c1e6b1b1822ee288da0083f5cbc17d73b1daded8949c64d07954d6" + } + }, + { + "destination": "docs/images/screenshots/sign-in-dark.png", + "sha256": "115110374215080ede0c37a304de3082b5600cce65c8dd6bc235a421f603ff5b", + "type": "screenshot", + "source": { + "source_sha": "11cc205c39294baeb3116038a5eb525519813eab", + "run": 39081400622, + "artifact_id": 12118982735, + "profile": "large", + "test": "ShellTest", + "prefix": "a1-sign-in", + "artifact_path": "validation/large/screenshots/a1-sign-in-screen.png", + "review_sha256": "28c904d299a31c1a912900c8596ce19f8dec7ce930830230a419bf6631267510" + } + }, + { + "destination": "docs/images/screenshots/tablet-home.png", + "sha256": "6b0f70940cbf2460a147e2f4ab22470ba69b307a5029e1ccf8a84faf80e542b8", + "type": "screenshot", + "source": { + "source_sha": "11cc205c39294baeb3116038a5eb525519813eab", + "run": 39081400622, + "artifact_id": 12119556424, + "profile": "small-tablet", + "test": "ShellTest", + "prefix": "a1-home", + "artifact_path": "validation/small-tablet/screenshots/a1-home-screen.png", + "review_sha256": "c52458a3be68f2aa53bd7138b6bad7353ddf228b761b27b4f49dc24dc782c9bf" + } + }, + { + "destination": "docs/images/screenshots/request-receipt.png", + "sha256": "b0d404a2d652217ba29ebfb3a4c79db1001a55cdc5433379ab3c4bc04dcb102f", + "type": "screenshot", + "source": { + "source_sha": "11cc205c39294baeb3116038a5eb525519813eab", + "run": 39081400622, + "artifact_id": 12119480990, + "profile": "phone", + "test": "MemberActionsJourneyTest", + "prefix": "a3-request-receipt", + "artifact_path": "validation/phone/screenshots/a3-request-receipt-screen.png", + "review_sha256": "49b29adb90c1e6b1b1822ee288da0083f5cbc17d73b1daded8949c64d07954d6" + } + }, + { + "destination": "docs/images/screenshots/account-devices.png", + "sha256": "fa54dbf09281ab6b311743af6e67e07cb460f6994727233d58d5790cec9600e2", + "type": "screenshot", + "source": { + "source_sha": "11cc205c39294baeb3116038a5eb525519813eab", + "run": 39081400622, + "artifact_id": 12119480990, + "profile": "phone", + "test": "AccountManagementJourneyTest", + "prefix": "a7-global-devices-no-membership", + "artifact_path": "validation/phone/screenshots/a7-global-devices-no-membership-screen.png", + "review_sha256": "49b29adb90c1e6b1b1822ee288da0083f5cbc17d73b1daded8949c64d07954d6" + } + }, + { + "destination": "docs/images/screenshots/staff-confirmation.png", + "sha256": "700badbb262c825c63632e3cf1453ab6613792e47ffefe1864f5e835db090ef3", + "type": "screenshot", + "source": { + "source_sha": "11cc205c39294baeb3116038a5eb525519813eab", + "run": 39081400622, + "artifact_id": 12119480990, + "profile": "phone", + "test": "StaffJourneyTest", + "prefix": "a6-app-staff-request-confirmation", + "artifact_path": "validation/phone/screenshots/a6-app-staff-request-confirmation-screen.png", + "review_sha256": "49b29adb90c1e6b1b1822ee288da0083f5cbc17d73b1daded8949c64d07954d6" + } + } + ] +} diff --git a/docs/images/brand/space-works-dark.png b/docs/images/brand/space-works-dark.png new file mode 100644 index 0000000..a407cb0 Binary files /dev/null and b/docs/images/brand/space-works-dark.png differ diff --git a/docs/images/brand/space-works-light.png b/docs/images/brand/space-works-light.png new file mode 100644 index 0000000..78253b7 Binary files /dev/null and b/docs/images/brand/space-works-light.png differ diff --git a/docs/images/screenshots/account-devices.png b/docs/images/screenshots/account-devices.png new file mode 100644 index 0000000..071cf0a Binary files /dev/null and b/docs/images/screenshots/account-devices.png differ diff --git a/docs/images/screenshots/introduction.png b/docs/images/screenshots/introduction.png new file mode 100644 index 0000000..8acbb31 Binary files /dev/null and b/docs/images/screenshots/introduction.png differ diff --git a/docs/images/screenshots/request-receipt.png b/docs/images/screenshots/request-receipt.png new file mode 100644 index 0000000..314efae Binary files /dev/null and b/docs/images/screenshots/request-receipt.png differ diff --git a/docs/images/screenshots/sign-in-dark.png b/docs/images/screenshots/sign-in-dark.png new file mode 100644 index 0000000..edd894a Binary files /dev/null and b/docs/images/screenshots/sign-in-dark.png differ diff --git a/docs/images/screenshots/staff-confirmation.png b/docs/images/screenshots/staff-confirmation.png new file mode 100644 index 0000000..274f8a2 Binary files /dev/null and b/docs/images/screenshots/staff-confirmation.png differ diff --git a/docs/images/screenshots/tablet-home.png b/docs/images/screenshots/tablet-home.png new file mode 100644 index 0000000..b963114 Binary files /dev/null and b/docs/images/screenshots/tablet-home.png differ diff --git a/docs/licenses/FONT-PROVENANCE.md b/docs/licenses/FONT-PROVENANCE.md index b9f5ff3..329b713 100644 --- a/docs/licenses/FONT-PROVENANCE.md +++ b/docs/licenses/FONT-PROVENANCE.md @@ -1,3 +1,3 @@ # Unbounded native font provenance -`app/src/main/res/font/unbounded.ttf` was converted without outlines/name/glyph changes from `design/fonts/Unbounded-Variable.woff2`, pinned design commit fd28f825086a2cac7fa297881f1860b9d332db69. Isolated build tooling: fontTools 4.67.0, WOFF2 decompression only; wght 200–900. TTF SHA256 87526b420190c9008a880bbf1780ab8c1563d55abb772a9cc6ed7e7b81306c3d. The bundled SIL OFL permits app embedding. Use weight500–600 for a short brand accent phrase only; platform Roboto remains body/UI pending Gilroy app-embedding clearance. +`app/src/main/res/font/unbounded.ttf` was converted without outlines/name/glyph changes from `design/fonts/Unbounded-Variable.woff2`, pinned design commit fd28f825086a2cac7fa297881f1860b9d332db69. Isolated build tooling: fontTools 4.67.0, WOFF2 decompression only; wght 200–900. TTF SHA256 87526b420190c9008a880bbf1780ab8c1563d55abb772a9cc6ed7e7b81306c3d. The bundled SIL OFL permits app embedding. Use weight500–600 for a short brand accent phrase only; body/UI now uses the owner-supplied Gilroy family with explicit native weights and system scaling. Separate owner confirmation of Gilroy app-embedding/distribution rights remains a release input; see `Gilroy-native-fonts.md`. The complete existing Unbounded copyright/OFL text is also copied unchanged into `app/src/main/assets/licenses/OFL-Unbounded.txt`, and APK packaging checks compare its exact bytes with `docs/licenses/OFL-Unbounded.txt`. diff --git a/docs/licenses/Gilroy-native-fonts.md b/docs/licenses/Gilroy-native-fonts.md new file mode 100644 index 0000000..b8308a0 --- /dev/null +++ b/docs/licenses/Gilroy-native-fonts.md @@ -0,0 +1,9 @@ +# Owner-supplied Gilroy native fonts + +The pinned Design submodule (`fd28f825086a2cac7fa297881f1860b9d332db69`) contains the owner's Gilroy Regular, Medium, SemiBold and Bold WOFF files. The owner requested their use for the full mobile brand implementation. Their origin and license distinction remain documented in `design/fonts/README.md`; the legacy free Light/ExtraBold EULA does not license these four supplied faces. + +`scripts/brand_fonts.py` reconstructs a native sfnt/TTF container using Python's standard library. Every decompressed table is preserved byte for byte, including glyph outlines, character coverage, names, layout and metrics. Only the `head.checkSumAdjustment` changes to account for the native container directory. No timestamps, family names, weight metadata or license metadata are rewritten. The generated `app/build/generated/brand-fonts.json` records input/output SHA-256 hashes and explicit Android weight assignments. + +The supplied OS/2 tables report weight 400 for all four distinct faces. `BrandFontFamily` explicitly registers their actual intended weights 400/500/600/700. Material's complete type scale retains its native sizes, line heights and letter spacing; system text scaling and bold-text adjustment remain active. Unbounded stays limited to short accent phrases. + +No font is fetched at runtime. White-label owners replacing these assets must supply their own licensed WOFF1 faces and update the four `font_*` paths in `branding/brand.json`. diff --git a/docs/mobile-auth-ux-spec.md b/docs/mobile-auth-ux-spec.md index 85ee35f..c42a968 100644 --- a/docs/mobile-auth-ux-spec.md +++ b/docs/mobile-auth-ux-spec.md @@ -10,7 +10,7 @@ The original mobile PRD remains the scope owner: `SpaceWorks/docs/mobile/MOBILE- Contract reference: the apps' `api/openapi.json`, SHA-256 `d5ec904eb2a52ca948ee38dae7840c8859a43e034a22f3b8dc478fd8851cbb9b`. Paths below include `/api/v1`, as do the generated operations. Configure the server origin without duplicating that prefix. A different schema in a local backend worktree is not a replacement contract. -Brand inputs: `design/tokens/tokens.json` is authoritative for color; `tokens/mobile-motion.json` is the shared machine-readable mobile motion contract generated into each app; `design/docs/ui-brand-spec.md` supplies the logo, spacing, motion provenance and voice; `design/docs/ux-yuyu-spec.md` and `design/docs/ux-screens-longtail.md` supply task-based layouts and states; `design/brand/space-works-brand-guidelines-v1.pdf` and its supplied page exports support visual verification. System fonts remain the mobile default until an app-embedding license for Gilroy is confirmed. The supplied WOFF files do not establish that license. +Brand inputs: `design/tokens/tokens.json` is authoritative for color; `tokens/mobile-motion.json` is the shared machine-readable mobile motion contract generated into each app; `design/docs/ui-brand-spec.md` supplies the logo, spacing, motion provenance and voice; `design/docs/ux-yuyu-spec.md` and `design/docs/ux-screens-longtail.md` supply task-based layouts and states; `design/brand/space-works-brand-guidelines-v1.pdf` and its supplied page exports support visual verification. The owner subsequently requested the supplied Gilroy Regular/Medium/SemiBold/Bold faces for native content, with explicit native weight mapping and full system text scaling; Unbounded remains a short accent only. Preserve each font’s provenance. The owner’s asset-use instruction does not itself establish app-embedding/distribution rights: that separate legal release input must be confirmed, and the old free Light/ExtraBold EULA does not license these four supplied faces. ## 2. Shared visual language diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f57a814..2efbeaa 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1 +1,2 @@ distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 diff --git a/scripts/brand_fonts.py b/scripts/brand_fonts.py new file mode 100644 index 0000000..7852a6f --- /dev/null +++ b/scripts/brand_fonts.py @@ -0,0 +1,89 @@ +"""Repackage supplied WOFF1 sfnt tables as Android TTF; no font editing or dependencies.""" +from pathlib import Path +import hashlib +import json +import struct +import zlib +import xml.etree.ElementTree as ET + +FACES = {'regular': 400, 'medium': 500, 'semibold': 600, 'bold': 700} + + +def checksum(data): + padded = data + b'\0' * (-len(data) % 4) + return sum(struct.unpack('>' + 'I' * (len(padded) // 4), padded)) & 0xffffffff + + +def woff_tables(data): + if len(data) < 44 or data[:4] != b'wOFF': + raise ValueError('Brand font must be a WOFF1 sfnt') + _, flavor, length, count, reserved, total = struct.unpack('>4sIIHHI', data[:20]) + if length != len(data) or reserved or not count or 44 + count * 20 > length: + raise ValueError('Invalid WOFF directory') + tables = {} + for i in range(count): + tag, offset, compressed, original, expected = struct.unpack('>4sIIII', data[44+i*20:64+i*20]) + if tag in tables or offset + compressed > length or compressed > original: + raise ValueError('Invalid WOFF table') + raw = data[offset:offset+compressed] + if compressed != original: + raw = zlib.decompress(raw) + verified = raw[:8] + b'\0' * 4 + raw[12:] if tag == b'head' else raw + if len(raw) != original or checksum(verified) != expected: + raise ValueError('WOFF table checksum mismatch') + tables[tag] = raw + return flavor, total, tables + + +def native_font(data): + flavor, total, tables = woff_tables(data) + count = len(tables) + selector = count.bit_length() - 1 + search = (1 << selector) * 16 + header = struct.pack('>IHHHH', flavor, count, search, selector, count*16-search) + directory = bytearray() + body = bytearray() + head_offset = None + for tag, raw in sorted(tables.items()): + offset = 12 + count*16 + len(body) + if tag == b'head': + raw = raw[:8] + b'\0'*4 + raw[12:] + head_offset = offset + directory.extend(struct.pack('>4sIII', tag, checksum(raw), offset, len(raw))) + body.extend(raw + b'\0' * (-len(raw) % 4)) + result = bytearray(header + directory + body) + if head_offset is None or len(result) != total: + raise ValueError('Invalid sfnt size or missing head table') + struct.pack_into('>I', result, head_offset+8, (0xb1b0afba-checksum(result)) & 0xffffffff) + return bytes(result) + + +def generate_brand_fonts(root, brand): + root = Path(root) + prepared = {} + manifest = {} + for face, weight in FACES.items(): + source = root / brand['font_' + face] + original = source.read_bytes() + prepared[face] = native_font(original) + manifest[face] = {'source': brand['font_' + face], 'weight': weight, + 'source_sha256': hashlib.sha256(original).hexdigest(), + 'ttf_sha256': hashlib.sha256(prepared[face]).hexdigest()} + output = root/'app/build/generated/res/brand/font' + output.mkdir(parents=True, exist_ok=True) + for face, data in prepared.items(): + (output/('gilroy_' + face + '.ttf')).write_bytes(data) + # Both platform and AndroidX parsers must see actual weights; the supplied OS/2 + # metadata is intentionally preserved and reports 400 for every source face. + family = ET.Element('font-family', {'xmlns:android': 'http://schemas.android.com/apk/res/android', + 'xmlns:app': 'http://schemas.android.com/apk/res-auto'}) + for face, weight in FACES.items(): + attributes = {} + for namespace in ('android', 'app'): + attributes.update({namespace+':font': '@font/gilroy_'+face, + namespace+':fontWeight': str(weight), namespace+':fontStyle': 'normal'}) + ET.SubElement(family, 'font', attributes) + ET.ElementTree(family).write(output/'gilroy.xml', encoding='utf-8', xml_declaration=True) + report = root/'app/build/generated/brand-fonts.json' + report.write_text(json.dumps(manifest, indent=2)+'\n', encoding='utf-8') + return manifest diff --git a/scripts/check_native_packaging.py b/scripts/check_native_packaging.py index 1c95e4f..7e7d2ef 100644 --- a/scripts/check_native_packaging.py +++ b/scripts/check_native_packaging.py @@ -4,6 +4,7 @@ This is binary/package validation, not a claim of runtime testing on a 16 KB device. """ import argparse +import hashlib import json import os import pathlib @@ -13,6 +14,8 @@ import zipfile PAGE = 16 * 1024 +FONT_LICENSE_PATH = 'assets/licenses/OFL-Unbounded.txt' +FONT_LICENSE_SOURCE = pathlib.Path(__file__).resolve().parents[1] / 'docs/licenses/OFL-Unbounded.txt' def elf_load_alignments(data): @@ -41,6 +44,11 @@ def elf_load_alignments(data): def inspect_apk(path): libraries = [] with zipfile.ZipFile(path) as apk: + if apk.namelist().count(FONT_LICENSE_PATH) != 1: + raise ValueError('APK must contain exactly one full Unbounded copyright/OFL license') + license_bytes = apk.read(FONT_LICENSE_PATH) + if license_bytes != FONT_LICENSE_SOURCE.read_bytes(): + raise ValueError('Packaged Unbounded copyright/OFL license must match the existing source bytes') for item in apk.infolist(): if not item.filename.startswith(('lib/arm64-v8a/', 'lib/x86_64/')) or not item.filename.endswith('.so'): continue @@ -49,7 +57,9 @@ def inspect_apk(path): except ValueError as error: raise ValueError(f'{item.filename}: {error}') from error libraries.append({'path': item.filename, 'load_alignments': alignments}) - return {'apk': str(path), 'elf64_libraries': libraries, 'elf64_count': len(libraries)} + return {'apk': str(path), 'elf64_libraries': libraries, 'elf64_count': len(libraries), + 'font_license': {'path': FONT_LICENSE_PATH, 'sha256': hashlib.sha256(license_bytes).hexdigest(), + 'source': 'docs/licenses/OFL-Unbounded.txt', 'exact_source_bytes': True}} def find_zipalign(): diff --git a/scripts/prepare.py b/scripts/prepare.py index 2498dc3..e1b92f7 100644 --- a/scripts/prepare.py +++ b/scripts/prepare.py @@ -2,9 +2,11 @@ from pathlib import Path import json,xml.etree.ElementTree as ET,re from brand_links import generate_brand_links +from brand_fonts import generate_brand_fonts root=Path(__file__).resolve().parents[1] brand=json.loads((root/'branding/brand.json').read_text(encoding='utf-8')) generate_brand_links(root, brand) +generate_brand_fonts(root, brand) tokens=json.loads((root/brand['theme_tokens']).read_text(encoding='utf-8')) out=root/'app/build/generated/source/brand/org/spaceworks/app/theme';out.mkdir(parents=True,exist_ok=True) lines=['package org.spaceworks.app.theme','', 'import androidx.compose.ui.graphics.Color','import androidx.compose.material3.*','', 'object BrandTokens {'] diff --git a/scripts/tests/test_brand_fonts.py b/scripts/tests/test_brand_fonts.py new file mode 100644 index 0000000..630b714 --- /dev/null +++ b/scripts/tests/test_brand_fonts.py @@ -0,0 +1,70 @@ +import importlib.util +import json +from pathlib import Path +import struct +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location('brand_fonts', ROOT/'scripts/brand_fonts.py') +FONTS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(FONTS) +BRAND = json.loads((ROOT/'branding/brand.json').read_text()) + + +class BrandFontsTest(unittest.TestCase): + def test_all_supplied_tables_names_outlines_and_metrics_survive_native_conversion(self): + for face in FONTS.FACES: + source = (ROOT/BRAND['font_'+face]).read_bytes() + flavor, total, original = FONTS.woff_tables(source) + converted = FONTS.native_font(source) + self.assertEqual(len(converted), total) + self.assertEqual(FONTS.checksum(converted), 0xb1b0afba) + self.assertEqual(struct.unpack('>I', converted[:4])[0], flavor) + count = struct.unpack('>H', converted[4:6])[0] + self.assertEqual(count, len(original)) + for index in range(count): + tag, expected, offset, length = struct.unpack('>4sIII', converted[12+16*index:28+16*index]) + actual = converted[offset:offset+length] + before = original[tag] + if tag == b'head': + # The sfnt container's only changed table bytes: its whole-file checksum. + actual = actual[:8] + b'\0'*4 + actual[12:] + before = before[:8] + b'\0'*4 + before[12:] + self.assertEqual(actual, before, (face, tag)) + self.assertEqual(FONTS.checksum(actual), expected) + self.assertTrue({b'name', b'cmap', b'glyf', b'hmtx', b'OS/2'} <= original.keys()) + + def test_generated_four_weights_and_manifest_are_deterministic(self): + with tempfile.TemporaryDirectory() as directory: + brand = {key: str((ROOT/value).resolve()) for key,value in BRAND.items() if key.startswith('font_')} + first = FONTS.generate_brand_fonts(directory, brand) + second = FONTS.generate_brand_fonts(directory, brand) + self.assertEqual(first, second) + self.assertEqual([first[face]['weight'] for face in FONTS.FACES], [400,500,600,700]) + self.assertEqual(len(list(Path(directory).rglob('*.ttf'))), 4) + self.assertEqual(len({record['ttf_sha256'] for record in first.values()}), 4) + from xml.etree import ElementTree as ET + family=ET.parse(Path(directory)/'app/build/generated/res/brand/font/gilroy.xml').getroot() + self.assertEqual(len(family),4) + for node,(face,weight) in zip(family,FONTS.FACES.items()): + for namespace in ('http://schemas.android.com/apk/res/android','http://schemas.android.com/apk/res-auto'): + self.assertEqual(node.get('{'+namespace+'}fontWeight'),str(weight)) + self.assertEqual(node.get('{'+namespace+'}font'),'@font/gilroy_'+face) + + def test_invalid_font_cannot_replace_an_existing_native_font_set(self): + with tempfile.TemporaryDirectory() as directory: + brand = {key: str((ROOT/value).resolve()) for key,value in BRAND.items() if key.startswith('font_')} + FONTS.generate_brand_fonts(directory, brand) + output = Path(directory)/'app/build/generated/res/brand/font/gilroy_regular.ttf' + before = output.read_bytes() + bad = Path(directory)/'broken.woff' + bad.write_bytes(b'not a font') + brand['font_bold'] = str(bad) + with self.assertRaises(ValueError): + FONTS.generate_brand_fonts(directory, brand) + self.assertEqual(before, output.read_bytes()) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_brand_links.py b/scripts/tests/test_brand_links.py index 4f42429..7386aa7 100644 --- a/scripts/tests/test_brand_links.py +++ b/scripts/tests/test_brand_links.py @@ -94,7 +94,7 @@ def test_actual_prepare_script_emits_links_and_rejects_invalid_brand_before_outp with tempfile.TemporaryDirectory() as folder: root = Path(folder) (root / 'scripts').mkdir() - for name in ('prepare.py', 'brand_links.py'): + for name in ('prepare.py', 'brand_links.py', 'brand_fonts.py'): shutil.copyfile(SCRIPTS / name, root / 'scripts' / name) (root / 'branding').mkdir() (root / 'branding/icon.svg').write_text('') @@ -109,6 +109,11 @@ def test_actual_prepare_script_emits_links_and_rejects_invalid_brand_before_outp for key in ('logo_symbol', 'logo_symbol_reversed', 'logo_symbol_one_colour_ink', 'logo_stacked', 'logo_stacked_reversed', 'logo_stacked_one_colour_ink'): brand[key] = 'branding/icon.svg' + for face, filename in [('regular', 'Regular'), ('medium', 'Medium'), ('semibold', 'SemiBold'), ('bold', 'Bold')]: + source = SCRIPTS.parent / 'design/fonts' / ('Gilroy-' + filename + '.woff') + target = root / ('branding/gilroy_' + face + '.woff') + shutil.copyfile(source, target) + brand['font_' + face] = str(target.relative_to(root)) config = root / 'branding/brand.json' config.write_text(json.dumps(brand)) command = [sys.executable, str(root / 'scripts/prepare.py')] diff --git a/scripts/tests/test_native_packaging.py b/scripts/tests/test_native_packaging.py index ef23bb6..49f515c 100644 --- a/scripts/tests/test_native_packaging.py +++ b/scripts/tests/test_native_packaging.py @@ -1,7 +1,9 @@ import importlib.util import pathlib import struct +import tempfile import unittest +import zipfile spec = importlib.util.spec_from_file_location('packaging_check', pathlib.Path(__file__).parents[1] / 'check_native_packaging.py') check = importlib.util.module_from_spec(spec) @@ -39,6 +41,32 @@ def test_truncated_invalid_or_empty_program_headers_fail(self): check.elf_load_alignments(data) +class BundledFontLicenseTests(unittest.TestCase): + def inspect(self, license_bytes): + with tempfile.TemporaryDirectory() as directory: + apk = pathlib.Path(directory) / 'fixture.apk' + with zipfile.ZipFile(apk, 'w') as archive: + archive.writestr('lib/arm64-v8a/libfixture.so', elf([(0, 0, 16384)])) + if license_bytes is not None: + archive.writestr(check.FONT_LICENSE_PATH, license_bytes) + return check.inspect_apk(apk) + + def test_full_existing_font_license_is_packaged_alongside_native_checks(self): + result = self.inspect(check.FONT_LICENSE_SOURCE.read_bytes()) + self.assertEqual(result['elf64_count'], 1) + self.assertEqual(result['elf64_libraries'][0]['load_alignments'], [16384]) + self.assertTrue(result['font_license']['exact_source_bytes']) + + def test_missing_full_font_license_cannot_pass_with_valid_native_library(self): + with self.assertRaisesRegex(ValueError, 'exactly one full Unbounded'): + self.inspect(None) + + def test_url_only_or_changed_license_cannot_replace_the_complete_text(self): + for content in [b'https://openfontlicense.org/', check.FONT_LICENSE_SOURCE.read_bytes()[:-1]]: + with self.subTest(content_bytes=len(content)), self.assertRaisesRegex(ValueError, 'source bytes'): + self.inspect(content) + + class DeviceFeatureTests(unittest.TestCase): BASE = "uses-feature-not-required: name='android.hardware.camera.any'\nsupports-screens: 'small' 'normal' 'large' 'xlarge'\n"