diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..78608b7
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,38 @@
+# Gitea Actions — runner must advertise label `ubuntu-latest` (act_runner).
+name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - name: Set up Gradle
+ uses: gradle/actions/setup-gradle@v5
+
+ - name: Set up Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Install Android Platform 34
+ run: |
+ sdkmanager "platforms;android-34" "build-tools;34.0.0"
+
+ - name: Accept Android licenses
+ run: yes | sdkmanager --licenses >/dev/null
+
+ - name: Run smoaVerify
+ run: ./gradlew smoaVerify --no-daemon
+
+ - name: Generate API docs (static ReDoc)
+ run: bash scripts/generate-api-docs.sh
diff --git a/.gitignore b/.gitignore
index 76a092e..aefda6c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,87 +1,101 @@
-# Built application files
+# =============================================================================
+# SMOA monorepo — Android app + Gradle modules + Spring Boot backend
+# =============================================================================
+
+# --- Android pack / bytecode ---
*.apk
+*.aab
*.aar
*.ap_
-*.aab
-
-# Files for the ART/Dalvik VM
*.dex
-# Java class files
+# --- JVM ---
*.class
+*.hprof
+hs_err_pid*
+replay_pid*
-# Generated files
+# --- Build outputs ---
bin/
gen/
out/
release/
-
-# Gradle files
-.gradle/
build/
+.gradle/
-# Local configuration file (sdk path, etc)
+# Kotlin incremental / metadata (Gradle Kotlin plugin; root + subprojects)
+.kotlin/
+
+# --- Local machine / SDK (never commit) ---
local.properties
+# Optional per-developer overrides (JAVA_HOME path, etc.)
+gradle.properties.local
-# Proguard folder generated by Eclipse
-proguard/
+# --- Spring Boot: file-based H2 when running from backend/ ---
+backend/data/
-# Log Files
-*.log
-
-# Android Studio Navigation editor temp files
-.navigation/
-
-# Android Studio captures folder
-captures/
-
-# IntelliJ
-*.iml
-.idea/workspace.xml
-.idea/tasks.xml
-.idea/gradle.xml
-.idea/assetWizardSettings.xml
-.idea/dictionaries
-.idea/libraries
-.idea/caches
-.idea/modules.xml
-
-# Keystore files
+# --- Secrets & environment (Docker Compose, local runs) ---
+.env
+.env.*
+!.env.example
+keystore.properties
*.jks
*.keystore
-# External native build folder generated in Android Studio 2.2 and later
-.externalNativeBuild
-.cxx/
+# Local Spring profiles (often contain secrets or machine-specific URLs)
+application-local.yml
+application-local.properties
+application-secrets.yml
+application-secret.yml
-# Google Services (e.g. APIs or Firebase)
+# Local Compose overrides (ports, bind mounts, secrets)
+docker-compose.override.yml
+
+# --- Google Services / Firebase (use a template in-repo if the team shares a non-secret stub) ---
google-services.json
-# Freeline
-freeline.py
-freeline/
-freeline_project_description.json
+# --- Native / CMake ---
+.externalNativeBuild/
+.cxx/
-# fastlane
-fastlane/report.xml
-fastlane/Preview.html
-fastlane/screenshots
-fastlane/test_output
-fastlane/readme.md
+# --- Logs & temp ---
+*.log
+*.tmp
+*.temp
+*~
+*.swp
+*.swo
+*.orig
-# Version control
-vcs.xml
+# --- Android Studio / legacy ---
+.navigation/
+captures/
+proguard/
-# lint
+# --- IntelliJ / Android Studio ---
+*.iml
+.idea/
+
+# --- Lint / tooling caches (AGP outputs live under build/; these cover edge cases) ---
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
-# lint/reports/
-# Android Profiling
-*.hprof
+# --- fastlane ---
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/
+fastlane/test_output/
+fastlane/readme.md
-# macOS
+# --- OS ---
.DS_Store
+Thumbs.db
+Desktop.ini
+# --- Local tooling archives ---
+commandlinetools-linux-*_latest.zip
+
+# --- Optional: local Action runner / CI experimentation ---
+.act/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index e0f15db..0be1c0c 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,3 +1,4 @@
{
- "java.configuration.updateBuildConfiguration": "automatic"
+ "java.configuration.updateBuildConfiguration": "automatic",
+ "java.compile.nullAnalysis.mode": "automatic"
}
\ No newline at end of file
diff --git a/README.md b/README.md
index df90442..6718f4e 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,11 @@
# Secure Mobile Operations Application (SMOA)
+[](https://gitea.d-bis.org/Sankofa_Phoenix/SMOA/actions)
+
+**Repository (monorepo):** [https://gitea.d-bis.org/Sankofa_Phoenix/SMOA](https://gitea.d-bis.org/Sankofa_Phoenix/SMOA) — `git clone https://gitea.d-bis.org/Sankofa_Phoenix/SMOA.git`
+
+**CI:** [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) (Gitea Actions) — runs `./gradlew smoaVerify` on push/PR.
+
**Android Foldable Devices – Online / Offline Mission Operations**
> **Constitutional Status**: This repository implements **SMOA**, a Tier-1 canonical sovereign system. See [NOTICE.md](./NOTICE.md) and [CANONICAL_CONSTITUTION.md](../CANONICAL_CONSTITUTION.md) for details.
@@ -72,9 +78,16 @@ smoa/
## Building
```bash
+# Backend tests + Android debug APK (recommended):
+./gradlew smoaVerify --no-daemon
+# or: ./scripts/build-all.sh
+
+# Full project build:
./gradlew build
```
+See [docs/development/BUILD.md](docs/development/BUILD.md) for details. Debug APK: `app/build/outputs/apk/debug/app-debug.apk`.
+
## Requirements
- Android Studio Hedgehog or later
@@ -144,4 +157,3 @@ See the `docs/` directory for comprehensive documentation:
## License
Proprietary - Government Use Only
-
diff --git a/TASKS.md b/TASKS.md
new file mode 100644
index 0000000..f419f24
--- /dev/null
+++ b/TASKS.md
@@ -0,0 +1,59 @@
+# SMOA – Master task list
+
+All areas; **status** reflects repo state after the 2026-02 optional-task sweep. Details: [TODO.md](./TODO.md).
+
+| # | Area | Task | Status |
+|---|------|------|--------|
+| B1 | Backend | Prod profile, Flyway, PostgreSQL docs | Done |
+| B2 | Backend | Tenant filter (`X-Unit`) | Done |
+| B3 | Backend | Pagination / OpenAPI params | Done |
+| B4 | Backend | ETag for GET `/api/v1/*` | Done |
+| B5 | Backend | Request ID filter | Done |
+| B6 | Backend | API versioning doc | Done |
+| B7 | Backend | Gradle Kotlin plugin + `FAIL_ON_PROJECT_REPOS` fix | Done |
+| B8 | Backend | Test profile (`application-test.yml`), passing tests | Done |
+| A1 | Android | Sync serialization (Gson), PullAPI, BuildConfig API URL/key | Done |
+| A2 | Android | InfrastructureManager STUN/signaling from BuildConfig | Done |
+| A3 | Android | Android 16 target doc | Done |
+| A4 | Android | Knox integration | **Doc:** [KNOX-INTEGRATION.md](docs/reference/KNOX-INTEGRATION.md) (SDK not bundled) |
+| A5 | Android | WebRTC PeerConnection | **Stub** + [WebRTCManager](modules/communications/.../WebRTCManager.kt); optional AAR |
+| A6 | Android | Connection quality | **Done:** [NetworkEstimatesConnectionQualityMonitor](modules/communications/.../NetworkEstimatesConnectionQualityMonitor.kt) |
+| A7 | Android | Screen share / file transfer | **Stub** flags in [VideoTransport](modules/meetings/.../VideoTransport.kt) |
+| A8 | Android | SmartCardReader | **Stub** (no PC/SC driver) |
+| I1 | iOS | App in Xcode | **External** – create project; contract in [docs/ios/README.md](docs/ios/README.md) |
+| I2 | iOS | Keychain / offline / biometrics / pinning | **Samples:** [docs/ios/SAMPLES.md](docs/ios/SAMPLES.md) |
+| W1 | Web | Scaffold + directory pull | Done |
+| W2 | Web | PWA manifest + service worker | Done |
+| W3 | Web | Offline queue helper | Done (`offline-queue.js`) |
+| W4 | Web | Deploy + CORS | **Doc:** [DEPLOY.md](docs/web-scaffold/DEPLOY.md) |
+| N1 | Infra | Nginx, docker-compose, k8s example | Done |
+| N2 | Infra | TURN/signaling self-host | **Doc:** [TURN-SIGNALING.md](docs/infrastructure/TURN-SIGNALING.md) |
+| D1 | Domain | NCIC live API | **Legal gate** – CJIS; local **Room** log: [NCICQueryDatabase](modules/ncic/.../NCICQueryDatabase.kt) |
+| D2 | Domain | ATF eTrace | **Legal gate**; **Room** drafts: [ATFFormDatabase](modules/atf/.../ATFFormDatabase.kt) |
+| D3 | Domain | eIDAS QTSP / EU trust lists | **Product gate** – stub in [EIDASService](core/eidas/.../EIDASService.kt) |
+| D4 | Domain | JCA digital signatures | **Done:** [DigitalSignatureService](core/signing/.../DigitalSignatureService.kt) |
+| D5 | Domain | Electronic seal verify | **Done:** SHA-256 compare in [ElectronicSealService](core/signing/.../ElectronicSealService.kt) |
+| D6 | Domain | XML/AS4 full stack | **Stub** + [core/as4/README.md](core/as4/README.md) |
+| D7 | Domain | Certificate revocation | Stub UNKNOWN; extend with OCSP/CRL |
+| T1 | Testing | `smoaVerify` in CI | Done (`.gitea/workflows/ci.yml`) |
+| T2 | Testing | Android coverage 80%+ | **Plan** – expand over time |
+| T3 | Testing | E2E | **Plan:** [E2E-PLAN.md](docs/testing/E2E-PLAN.md) |
+| X1 | Docs | BUILD.md, TASKS.md, TODO sweep | Done |
+| X2 | Build | **`./gradlew smoaVerify`** + `scripts/build-all.sh` (backend test + debug APK) | Done |
+| A9 | Android | TLS pinning + `AuthTokenInterceptor` + enterprise settings card | **Done** — [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt), [SECURITY-ENTERPRISE.md](docs/development/SECURITY-ENTERPRISE.md) |
+| A10 | Android | `SecureTokenStore`, OIDC `BuildConfig`, session lock overlay | **Done** — [security/](app/src/main/java/com/smoa/security/), [SessionLockOverlay](app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt) |
+| A11 | Android | Play Integrity client + Knox classpath probe | **Done** — [PlayIntegrityVerifier](app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt), [KnoxEnterpriseProbe](app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt) |
+| A12 | Android | Biometric-gated AES key scaffold | **Done** — [BiometricSecretsVault](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt); **Next:** CryptoObject + token wrap |
+| A13 | Android | **OIDC login flow** (AppAuth) + redirect activity / intent-filter | **Done** — [OidcLoginCoordinator](app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt), manifest `RedirectUriReceiverActivity` |
+| A14 | Android | **Refresh token** — OkHttp `Authenticator` + `SecureTokenStore` | **Done** — [TokenRefreshAuthenticator](app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt), token endpoint persisted after OIDC |
+| A15 | Android | Certificate pinning host from **hosted config** (`RemoteEndpointStore`) | **Done** — [NetworkPinningConfig.resolveBackendHost](app/src/main/java/com/smoa/network/NetworkPinningConfig.kt) + [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt) |
+| A16 | Android | **BiometricPrompt.CryptoObject** + keystore encrypt path | **Done** — [BiometricSecretsVault.tryCreateEncryptCryptoObject](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt), [BiometricAuthenticator.authenticateWithCryptoObject](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt) |
+| A17 | Android | Session lock: **device credential** fallback | **Done** — [BiometricAuthenticator.authenticateForSessionUnlock](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt), sign-in MFA path allows PIN/pattern |
+| A18 | Android | **Demo** auth + **SSO** when OIDC BuildConfig set | **Hybrid** — [AuthFlowHost](app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt) organization SSO + local demo; full IdP-only when you remove demo UI |
+| A19 | Android | Release **R8** + pinning + Integrity + ProGuard | **Verified** — `./gradlew :app:assembleRelease` succeeds; extend release checklists per tenant |
+| A20 | Android | **Multi-host** pins | **Done** — `tls_pin_spec` in hosted config + `SMOA_TLS_PIN_SPEC` BuildConfig; [CertificatePinnerFactory.buildFromMultiSpec](app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt) |
+| A21 | Android | Classification / watermark from **remote config** | **Done** — `classification_watermark_*` in [SmoaClientConfigJson](app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt), [ClassificationWatermark](app/src/main/java/com/smoa/ui/components/SmoaChrome.kt) |
+| T4 | Testing | Unit tests: session lock logic, certificate pinning | **Done** — [SessionLockLogicTest](app/src/test/java/com/smoa/security/SessionLockLogicTest.kt), [CertificatePinnerFactoryTest](app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt); `SecureTokenStore` needs on-device / crypto-capable runner |
+| T5 | Testing | UI tests: session lock, user settings | **Partial** — add `@HiltAndroidTest` + Compose rules on emulator when CI has a device |
+| T6 | Testing | CI: `scripts/generate-api-docs.sh` | **Done** — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) |
+| B9 | Backend | **Play Integrity** verify endpoint | **Stub** — `POST /api/v1/integrity/verify` returns **501** until Google API wired — [IntegrityAttestationController](backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt) |
diff --git a/TODO.md b/TODO.md
index 8834f78..e730d85 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,130 +1,101 @@
-# SMOA – Remaining and Optional Tasks
+# SMOA – Task status (post-sweep)
-Single list of **remaining** and **optional** work. References: [BACKEND-GAPS-AND-ROADMAP.md](backend/docs/BACKEND-GAPS-AND-ROADMAP.md), [REQUIREMENTS-ALIGNMENT.md](docs/reference/REQUIREMENTS-ALIGNMENT.md), [PLATFORM-REQUIREMENTS.md](docs/reference/PLATFORM-REQUIREMENTS.md), [IMPLEMENTATION_STATUS.md](docs/status/IMPLEMENTATION_STATUS.md).
+This file tracks **what the repository delivers** versus **what remains outside the repo** (legal gates, Xcode app binary, live vendor APIs).
+
+**Master index:** [TASKS.md](./TASKS.md)
+**Build:** [docs/development/BUILD.md](docs/development/BUILD.md) — run **`./gradlew smoaVerify --no-daemon`** (or `./scripts/build-all.sh`) for backend tests + debug APK.
---
-## Backend
+## Completed in repository (optional + remaining)
-### Remaining
-- [x] **Prod profile and DB** – Done: application-prod.yml, ddl-auto: validate, Flyway; document PostgreSQL in README.
-- [x] **Unit/tenant scoping** – Done: TenantFilter when smoa.tenant.require-unit=true; X-Unit required for /api/v1.
-- [x] **Migrations** – Done: Flyway, V1__baseline.sql, baseline-on-migrate.
+### Backend
+- [x] Prod profile, Flyway, PostgreSQL documentation
+- [x] Tenant filter (`smoa.tenant.require-unit`, `X-Unit`)
+- [x] Pagination / OpenAPI `@Parameter`
+- [x] ETag (`ShallowEtagHeaderFilter`), Request ID, API versioning doc
+- [x] Gradle: `pluginManagement` for `:backend`, no project-local repos under `FAIL_ON_PROJECT_REPOS`
+- [x] Tests: `application-test.yml`, MockK fixes, `:backend:test` green
-### Optional
-- [x] **Pagination** – Done: @Parameter on PullController for since/limit.
-- [x] **ETag / If-None-Match** – Done: ShallowEtagHeaderFilter for /api/v1/*; GET list supports ETag and 304.
-- [x] **Request ID** – Done: RequestIdFilter (X-Request-Id, MDC).
-- [x] **API versioning** – Doc: backend/docs/API-VERSIONING.md (when to add v2, deprecation).
-- [x] **Fix Gradle/Kotlin plugin** – Resolve “plugin already on classpath with unknown version” so `./gradlew :backend:test` runs (root vs backend plugin alignment).
+### Android
+- [x] Sync/pull, Gson, BuildConfig backend URL + API key + STUN/signaling URLs
+- [x] Android 16 documentation
+- [x] **Connection quality:** `NetworkEstimatesConnectionQualityMonitor` (link bandwidth from `NetworkCapabilities`)
+- [x] **WebRTC / screen share / file transfer:** documented stubs; `WebRTCManager` + `VideoTransport` flags (no AAR bundled)
+- [x] **SmartCardReader:** explicit no-hardware stub
+- [x] **Knox:** integration guide only — [KNOX-INTEGRATION.md](docs/reference/KNOX-INTEGRATION.md)
+
+### iOS
+- [x] API contract + checklist + **Swift samples** — [docs/ios/README.md](docs/ios/README.md), [docs/ios/SAMPLES.md](docs/ios/SAMPLES.md)
+- [ ] **Shipped Xcode app** — create in a separate Xcode project (not stored here)
+
+### Web
+- [x] Scaffold: info, health, directory pull, touch-friendly UI
+- [x] **PWA:** `manifest.webmanifest`, `sw.js`, service worker registration
+- [x] **Offline helper:** `offline-queue.js` (IndexedDB queue pattern)
+- [x] **Deploy + CORS:** [docs/web-scaffold/DEPLOY.md](docs/web-scaffold/DEPLOY.md)
+
+### Infrastructure
+- [x] Nginx example, docker-compose, k8s Deployment/Service example
+- [x] **TURN/signaling:** [docs/infrastructure/TURN-SIGNALING.md](docs/infrastructure/TURN-SIGNALING.md)
+
+### Domain / compliance
+- [x] **ATF local storage:** Room `ATFFormDraftEntity` + DAO + `ATFFormDatabase`
+- [x] **NCIC local log:** Room `NCICQueryLogEntity` + DAO + `NCICQueryDatabase` (+ Room deps on `:modules:ncic`)
+- [x] **Digital signatures:** JCA `Signature` in `DigitalSignatureService`
+- [x] **Electronic seal:** SHA-256 content verification in `ElectronicSealService`
+- [x] **AS4 / eIDAS:** stubs documented; roadmap [core/as4/README.md](core/as4/README.md)
+- [x] NCIC/ATF/eIDAS **live** integrations: clearly marked as **simulation or product gate** in service code
+
+### Testing & CI
+- [x] Gitea Actions: `./gradlew smoaVerify --no-daemon` — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml)
+- [x] E2E plan doc — [docs/testing/E2E-PLAN.md](docs/testing/E2E-PLAN.md)
+- [ ] Android 80%+ coverage — ongoing goal
+
+### Enterprise / auth / networking (coding follow-ups)
+
+See [TASKS.md](./TASKS.md) rows **A9–A21**, **T4–T6**. Summary:
+
+- [ ] **OIDC client:** AppAuth or SSO WebView; redirect `intent-filter`; code exchange → `SecureTokenStore.persistTokens`
+- [ ] **Token refresh:** OkHttp `Authenticator` on 401 using `refresh_token`
+- [ ] **Pinning + dynamic URL:** resolve pin host from `RemoteEndpointStore` when backend URL comes only from hosted config
+- [ ] **Biometric + Keystore:** `Cipher` + `BiometricPrompt.CryptoObject` to wrap/unwrap refresh token with `BiometricSecretsVault`
+- [ ] **Session lock:** optional device-credential path (not only biometrics) where policy allows
+- [ ] **Play Integrity:** POST token to your backend; add **server** verification (Play Integrity API) — sample in backend
+- [ ] **Tests:** `SessionLockController`, pinning factory, `SecureTokenStore`, `navigateSmoa`, settings ViewModel
+- [ ] **Auth UX:** replace demo password/MFA in `AuthFlowHost` with IdP-driven flow or hybrid step-up
+- [ ] **Release:** R8/proguard shrink test with Integrity + OkHttp pinning enabled
+- [ ] **CI:** optional `generate-api-docs.sh` + publish `docs/api/generated`
+- [ ] **Multi-pin hosts:** if pull/config use different origins than `SMOA_BACKEND_BASE_URL`, extend `CertificatePinnerFactory`
+
+### Documentation
+- [x] Backend README, IMPLEMENTATION_STATUS “next steps”, BUILD.md, TASKS.md
---
-## Android app
+## Outside repository (cannot close without external parties)
-### Remaining
-- [x] **SyncService serialization** – Done: Gson in core:common; serialize* produce JSON bytes.
-- [x] **Pull on connect** – Done: PullAPI + BackendPullAPI; startSync() runs pull when online and emits to pullResults (merge by observing modules).
-- [x] **API key in app** – Done: BuildConfig.SMOA_API_KEY, passed to BackendSyncAPI (build with -Psmoa.api.key=…).
-- [x] **Android 16 doc** – Done: [docs/reference/ANDROID-16-TARGET.md](docs/reference/ANDROID-16-TARGET.md). Actual SDK bump when AGP 8.5+ is adopted.
-
-### Optional
-- [ ] **Knox integration** – If required, integrate Knox SDK (e.g. secure storage, VPN) for target devices; Knox API level 39 is supported on primary device.
-- [ ] **WebRTC full integration** – Replace WebRTCManager TODOs with actual PeerConnection, audio/video capture, and track setup when library is fully integrated.
-- [ ] **Connection quality from WebRTC** – Replace StubConnectionQualityMonitor with implementation that uses WebRTC `getStats()` (bandwidth, RTT, loss) and calls SmartRoutingService/AdaptiveCodecSelector.
-- [x] **InfrastructureManager endpoints** – Done: BuildConfig SMOA_STUN_URLS, SMOA_SIGNALING_URLS; SMOAApplication configures STUN and signaling at startup; TURN set programmatically (see modules/communications/README.md).
-- [ ] **Screen sharing / file transfer** – Implement TODOs in VideoTransport for screen sharing and file transfer in meetings.
-- [ ] **SmartCardReader** – Implement actual card detection, connection, disconnection (or remove if not required).
-
----
-
-## iOS (last 3 generations)
-
-### Remaining
-- [ ] **iOS app project** – Scaffold: [docs/ios/README.md](docs/ios/README.md). Create full app (Swift/SwiftUI) targeting iOS 15, 16, 17.
-- [ ] **Keychain for API key** – To implement in iOS app.
-- [ ] **Offline queue** – To implement in iOS app (queue sync when offline; retry when online).
-
-### Optional
-- [ ] **Face ID / Touch ID** – Optional app unlock or sensitive-action auth.
-- [ ] **Certificate pinning** – Optional for API calls.
-
----
-
-## Web Dapp (Desktop / Laptop + touch)
-
-### Remaining
-- [x] **Web scaffold expand** – Done: [docs/web-scaffold/index.html](docs/web-scaffold/index.html) – API info, health, **Pull directory** (GET /api/v1/directory, list display, 304 handling); vanilla JS, no build step. Full SPA (React/Vue/Svelte) remains optional.
-- [ ] **Build and host** – Build pipeline and HTTPS hosting when SPA is ready.
-- [ ] **CORS** – Configure backend `smoa.cors.allowed-origins` for web app origin(s) in production.
-
-### Optional
-- [ ] **Offline** – Service Worker + Cache API; queue sync in IndexedDB/localStorage and flush when online.
-- [ ] **PWA** – Installable; optional offline shell.
-
----
-
-## Infrastructure
-
-### Optional
-- [x] **Reverse proxy** – Done: [nginx-smoa.conf.example](docs/infrastructure/nginx-smoa.conf.example), [docker-compose.yml](docker-compose.yml).
-- [ ] **TURN / signaling** – Host TURN and/or signaling for WebRTC if not using external services.
-- [x] **k8s manifests** – Done: [docs/infrastructure/k8s/backend-deployment.yaml](docs/infrastructure/k8s/backend-deployment.yaml) (Deployment, Service, optional Secret/ConfigMap).
-
----
-
-## Domain / compliance (optional, by priority)
-
-### High (requires approvals / provider selection)
-- [ ] **NCIC/III integration** – NCIC API (CJIS approval required).
-- [ ] **ATF eTrace** – ATF eTrace API (federal approval required).
-- [ ] **eIDAS QTSP** – Integrate with Qualified Trust Service Provider; qualified signatures, timestamps, EU Trust Lists.
-
-### Medium
-- [ ] **Digital signatures** – Full BouncyCastle (or similar) signature generation/verification; certificate chain validation.
-- [ ] **XML security** – Apache Santuario; XMLDSig/XMLEnc for AS4 and compliance.
-- [x] **CertificateManager.checkRevocationStatus** – Stub clarified: returns UNKNOWN; extend with OCSP/CRL for production.
-- [ ] **AS4 full implementation** – Full AS4 message envelope, ebMS 3.0, WS-RM, receipts, CPA (see AS4Service TODOs).
-- [x] **Report digital signature** – Done: ReportService.signReports + minimal SHA-256 content-hash signature; full signing via dedicated service when needed.
-- [ ] **Electronic seal** – Actual seal verification (ElectronicSealService TODO).
-
-### Lower / future
-- [x] **ZeroTrustFramework** – Replaced TODO with “Minimal implementation; extend for production”.
-- [x] **ThreatDetection** – Replaced TODOs with “Minimal implementation; extend for production”.
-- [ ] **ATF form storage** – Add entities and storage for ATF forms (ATFFormDatabase TODO).
-- [ ] **NCIC query storage** – Add entities for NCIC query storage (NCICQueryDatabase TODO).
-- [ ] **Compliance gaps** – Address domain-specific gaps in [COMPLIANCE_EVALUATION.md](docs/reference/COMPLIANCE_EVALUATION.md) (eIDAS QES, credential formats, barcode, NIBRS/UCR, etc.) per deployment priorities.
-
----
-
-## Testing
-
-### Optional
-- [ ] **Backend tests** – Fix Gradle plugin so `:backend:test` runs; add more integration tests as needed.
-- [ ] **Android unit/integration** – More unit tests for remaining modules; integration tests; UI tests; target 80%+ coverage where practical.
-- [ ] **E2E** – End-to-end tests for critical flows (sync, auth, meetings).
-
----
-
-## Documentation
-
-### Optional
-- [x] **README/back-end** – Done: Backend README lists DELETE/GET, rate limit, audit, Docker, tenant (smoa.tenant.require-unit), Request ID, Flyway, PostgreSQL prod, CORS (smoa.cors.allowed-origins), ETag.
-- [x] **Timeline** – Done: IMPLEMENTATION_STATUS.md “Next steps (short-term)” section added.
+| Item | Blocker |
+|------|---------|
+| Live **NCIC/III** API | CJIS approval, agency contract |
+| Live **ATF eTrace** | Federal approval, credentials |
+| **eIDAS QTSP** / EU Trust Lists | Trust service provider, operational URLs |
+| **Knox SDK** in binary | Samsung license / partner program |
+| **WebRTC** production media | Ship `google-webrtc` or vendor AAR + signaling |
+| **Full AS4** interop | Partner CPA, CXF/Santuario stack, operations |
+| **Compliance matrix gaps** | Deployment-specific priorities — see [COMPLIANCE_EVALUATION.md](docs/reference/COMPLIANCE_EVALUATION.md) |
---
## Summary
-| Area | Remaining | Optional |
-|------------|-----------|----------|
-| Backend | 0 | 2 |
-| Android | 0 | 6 |
-| iOS | 3 | 2 |
-| Web Dapp | 2 | 2 |
-| Infra | 0 | 2 |
-| Domain | 0 | 12+ |
-| Testing | 0 | 3 |
-| Docs | 0 | 0 |
+| Area | Repo-delivered | External / binary app |
+|------|----------------|------------------------|
+| Backend | All listed | — |
+| Android | All listed except Knox binary | Knox AAR, WebRTC AAR optional |
+| iOS | Docs + samples | Xcode application |
+| Web | Scaffold + PWA + deploy doc | Your HTTPS host + CORS env |
+| Infra | Examples + docs | Your servers |
+| Domain | Room + JCA + stubs | Live agency/vendor APIs |
-Use this file as the single checklist for remaining and optional work; link to it from [docs/README.md](docs/README.md) or [IMPLEMENTATION_STATUS.md](docs/status/IMPLEMENTATION_STATUS.md) as needed.
+Use [TASKS.md](./TASKS.md) for the flat checklist with file pointers.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 3ef8c6e..abca36b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,11 +1,17 @@
+import java.net.URI
+
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
- id("kotlin-kapt")
- id("dagger.hilt.android.plugin")
+ id("com.google.dagger.hilt.android")
id("kotlin-parcelize")
+ id("kotlin-kapt")
}
+val oidcRedirectProp = (project.findProperty("smoa.oidc.redirectUri") as? String)?.trim().orEmpty()
+val oidcRedirectUriDefault = if (oidcRedirectProp.isEmpty()) "com.smoa://oauth/redirect" else oidcRedirectProp
+val oauthRedirectUri = runCatching { URI(oidcRedirectUriDefault) }.getOrNull()
+
android {
namespace = "com.smoa"
compileSdk = AppConfig.compileSdk
@@ -21,7 +27,24 @@ android {
buildConfigField("String", "SMOA_API_KEY", "\"${project.findProperty("smoa.api.key") ?: ""}\"")
buildConfigField("String", "SMOA_STUN_URLS", "\"${project.findProperty("smoa.stun.urls") ?: ""}\"")
buildConfigField("String", "SMOA_SIGNALING_URLS", "\"${project.findProperty("smoa.signaling.urls") ?: ""}\"")
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ buildConfigField("String", "SMOA_CONFIG_URL", "\"${project.findProperty("smoa.config.url") ?: ""}\"")
+ val browserVpn = (project.findProperty("smoa.browser.vpnEnforced") as? String)?.toBoolean() == true
+ buildConfigField("boolean", "SMOA_BROWSER_VPN_ENFORCED", "$browserVpn")
+ // TLS: comma-separated OkHttp pins, e.g. sha256/AAAAAAAAAAA=,sha256/BBBBBBB= (see docs/development/SECURITY-ENTERPRISE.md)
+ buildConfigField("String", "SMOA_TLS_PINS", "\"${project.findProperty("smoa.tls.pins") ?: ""}\"")
+ // Multi-host pins: host|sha256/a,sha256/b;other.host|sha256/c (optional; overrides single-host when non-empty)
+ buildConfigField("String", "SMOA_TLS_PIN_SPEC", "\"${project.findProperty("smoa.tls.pinSpec") ?: ""}\"")
+ // OIDC placeholders — wire AppAuth / Custom Tabs in your IdP integration layer
+ buildConfigField("String", "SMOA_OIDC_ISSUER", "\"${project.findProperty("smoa.oidc.issuer") ?: ""}\"")
+ buildConfigField("String", "SMOA_OIDC_CLIENT_ID", "\"${project.findProperty("smoa.oidc.clientId") ?: ""}\"")
+ buildConfigField("String", "SMOA_OIDC_REDIRECT_URI", "\"${project.findProperty("smoa.oidc.redirectUri") ?: ""}\"")
+ val sessionTimeout = (project.findProperty("smoa.session.timeoutMinutes") as? String)?.toIntOrNull() ?: 15
+ buildConfigField("int", "SMOA_SESSION_TIMEOUT_MINUTES", "$sessionTimeout")
+ buildConfigField("String", "SMOA_PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER", "\"${project.findProperty("smoa.playIntegrity.cloudProjectNumber") ?: ""}\"")
+ buildConfigField("String", "SMOA_CLASSIFICATION_BUILD_MARKING", "\"${project.findProperty("smoa.classification.buildMarking") ?: "UNCONFIGURED"}\"")
+ manifestPlaceholders["appAuthRedirectScheme"] = oauthRedirectUri?.scheme ?: "com.smoa"
+ manifestPlaceholders["appAuthRedirectHost"] = oauthRedirectUri?.host ?: "oauth"
+ testInstrumentationRunner = "com.smoa.HiltTestRunner"
vectorDrawables {
useSupportLibrary = true
}
@@ -55,6 +78,10 @@ android {
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
+
+ hilt {
+ enableAggregatingTask = true
+ }
packaging {
resources {
@@ -73,15 +100,23 @@ android {
}
}
+kapt {
+ correctErrorTypes = true
+}
+
dependencies {
implementation(platform(Dependencies.composeBom))
implementation(Dependencies.composeUi)
implementation(Dependencies.composeUiGraphics)
implementation(Dependencies.composeUiToolingPreview)
implementation(Dependencies.composeMaterial3)
+ implementation("androidx.compose.material3:material3-window-size-class")
+ implementation("androidx.compose.material:material-icons-extended")
implementation(Dependencies.androidxActivityCompose)
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.lifecycleProcess)
// Navigation
implementation(Dependencies.navigationCompose)
@@ -90,6 +125,12 @@ dependencies {
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
implementation(Dependencies.hiltNavigationCompose)
+
+ implementation(Dependencies.roomRuntime)
+ implementation(Dependencies.roomKtx)
+ kapt(Dependencies.roomCompiler)
+ implementation("androidx.sqlite:sqlite:2.4.0")
+ implementation(Dependencies.sqlcipher)
// Core modules
implementation(project(":core:auth"))
@@ -127,14 +168,19 @@ dependencies {
implementation(Dependencies.retrofit)
implementation(Dependencies.retrofitGson)
implementation(Dependencies.okHttp)
+ implementation("net.openid:appauth:0.11.1")
+ implementation(Dependencies.playIntegrity)
+ implementation(Dependencies.coroutinesPlayServices)
// Testing
testImplementation(Dependencies.junit)
androidTestImplementation(Dependencies.androidxJunit)
androidTestImplementation(Dependencies.espressoCore)
+ androidTestImplementation(Dependencies.hiltAndroidTesting)
+ kaptAndroidTest(Dependencies.hiltAndroidCompiler)
+ androidTestImplementation(Dependencies.mockWebServer)
androidTestImplementation(platform(Dependencies.composeBom))
androidTestImplementation(Dependencies.composeUiTestJunit4)
debugImplementation(Dependencies.composeUiTooling)
debugImplementation(Dependencies.composeUiTestManifest)
}
-
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 3026518..3c96f0a 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -20,7 +20,37 @@
-keep class androidx.security.** { *; }
-dontwarn androidx.security.**
+# Play Integrity / Play Core
+-keep class com.google.android.play.core.integrity.** { *; }
+-dontwarn com.google.android.play.core.**
+
+# OkHttp / certificate pinning
+-dontwarn okhttp3.internal.platform.**
+
+# AppAuth (OIDC)
+-keep class net.openid.appauth.** { *; }
+-dontwarn net.openid.appauth.**
+
+# Gson / remote config / sync DTOs (field names must match JSON)
+-keep class com.smoa.config.SmoaClientConfigJson { *; }
+-keep class com.smoa.core.common.sync.CredentialSyncRequestDto { *; }
+-keep class com.smoa.sync.** { *; }
+
# Keep Kotlin coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
+# R8: optional references from CXF / Woodstox / Tink (desktop JDK & compile-only annotations)
+-dontwarn aQute.bnd.annotation.spi.ServiceProvider
+-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue
+-dontwarn com.google.errorprone.annotations.CheckReturnValue
+-dontwarn com.google.errorprone.annotations.Immutable
+-dontwarn com.google.errorprone.annotations.RestrictedApi
+-dontwarn javax.imageio.ImageIO
+-dontwarn javax.xml.stream.XMLEventFactory
+-dontwarn javax.xml.stream.XMLInputFactory
+-dontwarn javax.xml.stream.XMLOutputFactory
+-dontwarn javax.xml.stream.XMLResolver
+-dontwarn javax.xml.stream.util.XMLEventAllocator
+-dontwarn org.springframework.context.ApplicationContext
+
diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..de2746a
--- /dev/null
+++ b/app/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/androidTest/java/com/smoa/HiltTestRunner.kt b/app/src/androidTest/java/com/smoa/HiltTestRunner.kt
new file mode 100644
index 0000000..6901fab
--- /dev/null
+++ b/app/src/androidTest/java/com/smoa/HiltTestRunner.kt
@@ -0,0 +1,14 @@
+package com.smoa
+
+import android.app.Application
+import android.content.Context
+import androidx.test.runner.AndroidJUnitRunner
+
+/**
+ * Runs instrumented tests with [SMOAHiltTestApplication] so @HiltAndroidTest can inject app components.
+ */
+class HiltTestRunner : AndroidJUnitRunner() {
+ override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
+ return super.newApplication(cl, SMOAHiltTestApplication::class.java.name, context)
+ }
+}
diff --git a/app/src/androidTest/java/com/smoa/SMOAHiltTestApplication.kt b/app/src/androidTest/java/com/smoa/SMOAHiltTestApplication.kt
new file mode 100644
index 0000000..4c323f6
--- /dev/null
+++ b/app/src/androidTest/java/com/smoa/SMOAHiltTestApplication.kt
@@ -0,0 +1,10 @@
+package com.smoa
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+/**
+ * Test process entry point for Hilt-instrumented tests ([HiltTestRunner]).
+ */
+@HiltAndroidApp
+class SMOAHiltTestApplication : Application()
diff --git a/app/src/androidTest/java/com/smoa/api/BackendSyncCredentialConflictMockServerInstrumentedTest.kt b/app/src/androidTest/java/com/smoa/api/BackendSyncCredentialConflictMockServerInstrumentedTest.kt
new file mode 100644
index 0000000..ff5c7f5
--- /dev/null
+++ b/app/src/androidTest/java/com/smoa/api/BackendSyncCredentialConflictMockServerInstrumentedTest.kt
@@ -0,0 +1,74 @@
+package com.smoa.api
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.gson.Gson
+import com.smoa.api.dto.SyncResponseDto
+import com.smoa.core.common.Result
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.Base64
+
+/**
+ * HTTP integration test without a live backend. Uses OkHttp [MockWebServer] (WireMock is not
+ * typically used on-device; MockWebServer is the standard Android equivalent).
+ */
+@RunWith(AndroidJUnit4::class)
+class BackendSyncCredentialConflictMockServerInstrumentedTest {
+
+ @Test
+ fun syncCredential_conflict_decodesRemoteData() = runBlocking {
+ val remoteCredentialJson =
+ """{"credentialId":"c-mock","holderId":"h1","credentialType":"ID_CARD","issuer":null,"issuedAt":1,"expiresAt":2,"payloadJson":"{\"k\":1}","updatedAt":99}"""
+ val remoteB64 = Base64.getEncoder().encodeToString(remoteCredentialJson.toByteArray(Charsets.UTF_8))
+ val dto = SyncResponseDto(
+ success = false,
+ itemId = "c-mock",
+ serverTimestamp = 1000L,
+ conflict = true,
+ remoteDataBase64 = remoteB64,
+ message = "Conflict"
+ )
+ val bodyJson = Gson().toJson(dto)
+
+ val server = MockWebServer()
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody(bodyJson)
+ )
+ server.start()
+
+ val retrofit = Retrofit.Builder()
+ .baseUrl(server.url("/"))
+ .client(OkHttpClient())
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ val service = retrofit.create(BackendSyncApiService::class.java)
+ val api = BackendSyncAPI { Pair(service, null) }
+
+ val credentialBytes =
+ """{"credentialId":"c-mock","holderId":"h1","credentialType":"ID_CARD","clientUpdatedAt":1}"""
+ .toByteArray(Charsets.UTF_8)
+
+ val result = api.syncCredential(credentialBytes)
+
+ assertEquals("/api/v1/sync/credential", server.takeRequest().path)
+
+ server.shutdown()
+
+ assertTrue(result is Result.Success)
+ val data = (result as Result.Success).data
+ assertTrue(data.conflict)
+ assertArrayEquals(remoteCredentialJson.toByteArray(Charsets.UTF_8), data.remoteData)
+ }
+}
diff --git a/app/src/androidTest/java/com/smoa/sync/SyncConflictSnapshotRepositoryInstrumentedTest.kt b/app/src/androidTest/java/com/smoa/sync/SyncConflictSnapshotRepositoryInstrumentedTest.kt
new file mode 100644
index 0000000..981b12f
--- /dev/null
+++ b/app/src/androidTest/java/com/smoa/sync/SyncConflictSnapshotRepositoryInstrumentedTest.kt
@@ -0,0 +1,75 @@
+package com.smoa.sync
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import javax.inject.Inject
+
+@HiltAndroidTest
+@RunWith(AndroidJUnit4::class)
+class SyncConflictSnapshotRepositoryInstrumentedTest {
+
+ @get:Rule(order = 0)
+ val hiltRule = HiltAndroidRule(this)
+
+ @Inject
+ lateinit var repository: SyncConflictSnapshotRepository
+
+ @Inject
+ lateinit var store: SyncConflictSnapshotStore
+
+ @Before
+ fun setup() {
+ hiltRule.inject()
+ }
+
+ @After
+ fun tearDown() = runBlocking {
+ repository.clearAll()
+ }
+
+ @Test
+ fun observeSnapshots_emitsAfterSave() = runBlocking {
+ val payload = """{"orderId":"o-int","title":"T"}""".toByteArray(Charsets.UTF_8)
+ store.save(resourceType = "order", itemId = "o-int", body = payload)
+
+ val list = repository.observeSnapshots().first { rows ->
+ rows.any { it.itemId == "o-int" && it.resourceType == "order" }
+ }
+
+ val row = list.first { it.itemId == "o-int" }
+ assertEquals("order", row.resourceType)
+ assertTrue(row.jsonUtf8.contains("orderId"))
+ }
+
+ @Test
+ fun getSnapshot_and_deleteSnapshot() = runBlocking {
+ store.save("evidence", "e1", """{"evidenceId":"e1"}""".toByteArray(Charsets.UTF_8))
+
+ val got = repository.getSnapshot("evidence", "e1")
+ assertNotNull(got)
+ assertEquals("e1", got!!.itemId)
+
+ repository.deleteSnapshot("evidence", "e1")
+ assertEquals(null, repository.getSnapshot("evidence", "e1"))
+ }
+
+ @Test
+ fun observeByResourceType_filtersRows() = runBlocking {
+ store.save("report", "r1", "{}".toByteArray(Charsets.UTF_8))
+ store.save("order", "o2", "{}".toByteArray(Charsets.UTF_8))
+
+ val reports = repository.observeByResourceType("report").first { it.any { row -> row.itemId == "r1" } }
+ assertTrue(reports.all { it.resourceType == "report" })
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d63c597..e759e36 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,6 +42,21 @@
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/smoa/MainActivity.kt b/app/src/main/java/com/smoa/MainActivity.kt
index 834efce..cc75ed0 100644
--- a/app/src/main/java/com/smoa/MainActivity.kt
+++ b/app/src/main/java/com/smoa/MainActivity.kt
@@ -1,21 +1,36 @@
package com.smoa
+import android.app.Activity
import android.content.res.Configuration
import android.os.Bundle
-import androidx.activity.ComponentActivity
+import android.content.Intent
+import android.widget.Toast
import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
+import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
+import androidx.fragment.app.FragmentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.smoa.core.common.ConnectivityManager
+import androidx.lifecycle.lifecycleScope
import com.smoa.core.common.FoldableStateManager
-import com.smoa.ui.main.MainScreen
+import com.smoa.auth.AuthPreferencesStore
+import com.smoa.security.OidcLoginCoordinator
+import com.smoa.security.SessionLockController
+import com.smoa.ui.root.AppRoot
+import com.smoa.ui.theme.SMOATheme
import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
import javax.inject.Inject
+@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
-class MainActivity : ComponentActivity() {
+class MainActivity : FragmentActivity() {
@Inject
lateinit var connectivityManager: ConnectivityManager
@@ -50,8 +65,42 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var rbacFramework: com.smoa.core.auth.RBACFramework
+ @Inject
+ lateinit var authPreferencesStore: AuthPreferencesStore
+
+ @Inject
+ lateinit var sessionLockController: SessionLockController
+
+ @Inject
+ lateinit var oidcLoginCoordinator: OidcLoginCoordinator
+
+ private lateinit var oidcAuthLauncher: ActivityResultLauncher
+
override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
super.onCreate(savedInstanceState)
+
+ oidcAuthLauncher = registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ when (result.resultCode) {
+ Activity.RESULT_OK -> {
+ val data = result.data ?: return@registerForActivityResult
+ lifecycleScope.launch {
+ runCatching {
+ oidcLoginCoordinator.finishAuthorization(this@MainActivity, data)
+ }.onFailure {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.auth_oidc_failed),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ }
+ else -> oidcLoginCoordinator.cancelPendingFlow()
+ }
+ }
// Enable screen protection for the entire application
screenProtection.enableScreenProtection(this)
@@ -62,29 +111,21 @@ class MainActivity : ComponentActivity() {
// Update fold state on configuration change
updateFoldState()
- // Initialize default user session for testing
- // In production, this would come from authentication
- userSession.setUser(
- com.smoa.core.auth.UserInfo(
- userId = "user1",
- userName = "Test User",
- role = com.smoa.core.auth.RBACFramework.Role.OPERATOR,
- unit = "Unit1",
- clearanceLevel = null,
- missionAssignment = null
- )
- )
-
setContent {
- MaterialTheme {
+ val windowSizeClass = calculateWindowSizeClass(this)
+ SMOATheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
- MainScreen(
+ AppRoot(
+ activity = this,
+ windowSizeClass = windowSizeClass,
+ sessionLockController = sessionLockController,
+ authPreferencesStore = authPreferencesStore,
+ userSession = userSession,
connectivityManager = connectivityManager,
foldableStateManager = foldableStateManager,
- userSession = userSession,
rbacFramework = rbacFramework,
directoryService = directoryService,
communicationsService = communicationsService,
@@ -92,7 +133,21 @@ class MainActivity : ComponentActivity() {
browserService = browserService,
urlFilter = urlFilter,
screenProtection = screenProtection,
- vpnManager = vpnManager
+ vpnManager = vpnManager,
+ onOrganizationSignIn = {
+ lifecycleScope.launch {
+ runCatching {
+ val intent = oidcLoginCoordinator.prepareAuthorizationIntent()
+ oidcAuthLauncher.launch(intent)
+ }.onFailure {
+ Toast.makeText(
+ this@MainActivity,
+ getString(R.string.auth_oidc_failed),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ }
)
}
}
diff --git a/app/src/main/java/com/smoa/SMOAApplication.kt b/app/src/main/java/com/smoa/SMOAApplication.kt
index c051468..e99234b 100644
--- a/app/src/main/java/com/smoa/SMOAApplication.kt
+++ b/app/src/main/java/com/smoa/SMOAApplication.kt
@@ -1,6 +1,9 @@
package com.smoa
import android.app.Application
+import com.smoa.config.ClientConfigRefreshCoordinator
+import com.smoa.core.security.VPNManager
+import com.smoa.security.SessionLockController
import com.smoa.modules.communications.domain.InfrastructureManager
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -11,8 +14,20 @@ class SMOAApplication : Application() {
@Inject
lateinit var infrastructureManager: InfrastructureManager
+ @Inject
+ lateinit var clientConfigRefreshCoordinator: ClientConfigRefreshCoordinator
+
+ @Inject
+ lateinit var vpnManager: VPNManager
+
+ @Inject
+ lateinit var sessionLockController: SessionLockController
+
override fun onCreate() {
super.onCreate()
+ sessionLockController.start()
+ vpnManager.setBrowserVpnEnforced(BuildConfig.SMOA_BROWSER_VPN_ENFORCED)
+ clientConfigRefreshCoordinator.scheduleNonBlockingInitialLoad()
configureInfrastructure()
}
diff --git a/app/src/main/java/com/smoa/api/BackendPullAPI.kt b/app/src/main/java/com/smoa/api/BackendPullAPI.kt
index 26243a1..389c367 100644
--- a/app/src/main/java/com/smoa/api/BackendPullAPI.kt
+++ b/app/src/main/java/com/smoa/api/BackendPullAPI.kt
@@ -6,14 +6,18 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.HttpException
+/**
+ * PullAPI implementation; [resolveApi] returns service + key or null if base URL unavailable.
+ */
class BackendPullAPI(
- private val apiService: BackendPullApiService,
- private val apiKey: String? = null
+ private val resolveApi: () -> Pair?
) : PullAPI {
override suspend fun pullDirectory(unit: String?) = withContext(Dispatchers.IO) {
+ val (api, key) = resolveApi()
+ ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured"))
runCatching {
- val r = apiService.getDirectory(unit, unit, apiKey)
+ val r = api.getDirectory(unit, unit, key)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
@@ -23,8 +27,10 @@ class BackendPullAPI(
}
override suspend fun pullOrders(since: Long?, limit: Int, jurisdiction: String?) = withContext(Dispatchers.IO) {
+ val (api, key) = resolveApi()
+ ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured"))
runCatching {
- val r = apiService.getOrders(since, limit, jurisdiction, jurisdiction, apiKey)
+ val r = api.getOrders(since, limit, jurisdiction, jurisdiction, key)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
@@ -34,8 +40,10 @@ class BackendPullAPI(
}
override suspend fun pullEvidence(since: Long?, limit: Int, caseNumber: String?) = withContext(Dispatchers.IO) {
+ val (api, key) = resolveApi()
+ ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured"))
runCatching {
- val r = apiService.getEvidence(since, limit, caseNumber, apiKey)
+ val r = api.getEvidence(since, limit, caseNumber, key)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
@@ -45,8 +53,23 @@ class BackendPullAPI(
}
override suspend fun pullCredentials(since: Long?, limit: Int, holderId: String?) = withContext(Dispatchers.IO) {
+ val (api, key) = resolveApi()
+ ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured"))
runCatching {
- val r = apiService.getCredentials(since, limit, holderId, apiKey)
+ val r = api.getCredentials(since, limit, holderId, key)
+ if (!r.isSuccessful) throw HttpException(r)
+ r.body()?.bytes() ?: ByteArray(0)
+ }.fold(
+ onSuccess = { Result.Success(it) },
+ onFailure = { Result.Error(it) }
+ )
+ }
+
+ override suspend fun pullCredentialDetail(credentialId: String) = withContext(Dispatchers.IO) {
+ val (api, key) = resolveApi()
+ ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured"))
+ runCatching {
+ val r = api.getCredential(credentialId, key)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
@@ -56,8 +79,10 @@ class BackendPullAPI(
}
override suspend fun pullReports(since: Long?, limit: Int) = withContext(Dispatchers.IO) {
+ val (api, key) = resolveApi()
+ ?: return@withContext Result.Error(IllegalStateException("Backend base URL not configured"))
runCatching {
- val r = apiService.getReports(since, limit, apiKey)
+ val r = api.getReports(since, limit, key)
if (!r.isSuccessful) throw HttpException(r)
r.body()?.bytes() ?: ByteArray(0)
}.fold(
diff --git a/app/src/main/java/com/smoa/api/BackendPullApiService.kt b/app/src/main/java/com/smoa/api/BackendPullApiService.kt
index a7ea0b0..1637fac 100644
--- a/app/src/main/java/com/smoa/api/BackendPullApiService.kt
+++ b/app/src/main/java/com/smoa/api/BackendPullApiService.kt
@@ -4,6 +4,7 @@ import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header
+import retrofit2.http.Path
import retrofit2.http.Query
interface BackendPullApiService {
@@ -39,6 +40,12 @@ interface BackendPullApiService {
@Header("X-API-Key") apiKey: String?
): Response
+ @GET("api/v1/credentials/{credentialId}")
+ suspend fun getCredential(
+ @Path("credentialId") credentialId: String,
+ @Header("X-API-Key") apiKey: String?
+ ): Response
+
@GET("api/v1/reports")
suspend fun getReports(
@Query("since") since: Long?,
diff --git a/app/src/main/java/com/smoa/api/BackendSyncAPI.kt b/app/src/main/java/com/smoa/api/BackendSyncAPI.kt
index 69846f4..8ff9f9f 100644
--- a/app/src/main/java/com/smoa/api/BackendSyncAPI.kt
+++ b/app/src/main/java/com/smoa/api/BackendSyncAPI.kt
@@ -12,136 +12,88 @@ import java.util.Base64
/**
* SyncAPI implementation that calls the SMOA backend over HTTP.
- * Use when BuildConfig.SMOA_BACKEND_BASE_URL is set.
+ * [resolveApi] returns service + API key, or null if the backend base URL is not available yet.
*/
class BackendSyncAPI(
- private val baseUrl: String,
- private val apiService: BackendSyncApiService,
- private val apiKey: String? = null
+ private val resolveApi: () -> Pair?
) : com.smoa.core.common.SyncAPI {
private val jsonType = "application/json; charset=utf-8".toMediaType()
+ private inline fun withApi(block: (BackendSyncApiService, String?) -> SyncResponse): Result {
+ val (api, key) = resolveApi()
+ ?: return Result.Error(IllegalStateException("Backend base URL not configured"))
+ return runCatching { block(api, key) }.fold(
+ onSuccess = { Result.Success(it) },
+ onFailure = { Result.Error(it) }
+ )
+ }
+
override suspend fun syncDirectoryEntry(entryData: ByteArray): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val body = entryData.toRequestBody(jsonType)
- val response = apiService.syncDirectory(body, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key ->
+ mapResponse(api.syncDirectory(entryData.toRequestBody(jsonType), key))
+ }
}
override suspend fun syncOrder(orderData: ByteArray): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val body = orderData.toRequestBody(jsonType)
- val response = apiService.syncOrder(body, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key ->
+ mapResponse(api.syncOrder(orderData.toRequestBody(jsonType), key))
+ }
}
override suspend fun syncEvidence(evidenceData: ByteArray): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val body = evidenceData.toRequestBody(jsonType)
- val response = apiService.syncEvidence(body, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key ->
+ mapResponse(api.syncEvidence(evidenceData.toRequestBody(jsonType), key))
+ }
}
override suspend fun syncCredential(credentialData: ByteArray): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val body = credentialData.toRequestBody(jsonType)
- val response = apiService.syncCredential(body, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key ->
+ mapResponse(api.syncCredential(credentialData.toRequestBody(jsonType), key))
+ }
}
override suspend fun syncReport(reportData: ByteArray): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val body = reportData.toRequestBody(jsonType)
- val response = apiService.syncReport(body, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key ->
+ mapResponse(api.syncReport(reportData.toRequestBody(jsonType), key))
+ }
}
override suspend fun deleteDirectory(id: String): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val response = apiService.deleteDirectory(id, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key -> mapResponse(api.deleteDirectory(id, key)) }
}
override suspend fun deleteOrder(orderId: String): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val response = apiService.deleteOrder(orderId, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key -> mapResponse(api.deleteOrder(orderId, key)) }
}
override suspend fun deleteEvidence(evidenceId: String): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val response = apiService.deleteEvidence(evidenceId, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key -> mapResponse(api.deleteEvidence(evidenceId, key)) }
}
override suspend fun deleteCredential(credentialId: String): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val response = apiService.deleteCredential(credentialId, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key -> mapResponse(api.deleteCredential(credentialId, key)) }
}
override suspend fun deleteReport(reportId: String): Result =
withContext(Dispatchers.IO) {
- runCatching {
- val response = apiService.deleteReport(reportId, apiKey)
- mapResponse(response)
- }.fold(
- onSuccess = { it },
- onFailure = { Result.Error(it) }
- )
+ withApi { api, key -> mapResponse(api.deleteReport(reportId, key)) }
}
- private fun mapResponse(response: retrofit2.Response): Result {
+ private fun mapResponse(response: retrofit2.Response): SyncResponse {
if (!response.isSuccessful) {
- return Result.Error(HttpException(response))
+ throw HttpException(response)
}
- val dto = response.body() ?: return Result.Error(NullPointerException("Empty body"))
+ val dto = response.body() ?: throw NullPointerException("Empty body")
val remoteData = dto.remoteDataBase64?.let { base64 ->
try {
Base64.getDecoder().decode(base64)
@@ -149,15 +101,13 @@ class BackendSyncAPI(
null
}
}
- return Result.Success(
- SyncResponse(
- success = dto.success,
- itemId = dto.itemId,
- serverTimestamp = dto.serverTimestamp,
- conflict = dto.conflict,
- remoteData = remoteData,
- message = dto.message
- )
+ return SyncResponse(
+ success = dto.success,
+ itemId = dto.itemId,
+ serverTimestamp = dto.serverTimestamp,
+ conflict = dto.conflict,
+ remoteData = remoteData,
+ message = dto.message
)
}
}
diff --git a/app/src/main/java/com/smoa/api/SyncRetrofitHolder.kt b/app/src/main/java/com/smoa/api/SyncRetrofitHolder.kt
new file mode 100644
index 0000000..29571d6
--- /dev/null
+++ b/app/src/main/java/com/smoa/api/SyncRetrofitHolder.kt
@@ -0,0 +1,77 @@
+package com.smoa.api
+
+import com.smoa.BuildConfig
+import com.smoa.config.RemoteEndpointStore
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Resolves Retrofit and API keys from [RemoteEndpointStore] with [BuildConfig] fallback.
+ * Rebuilds the client when the effective base URL changes (e.g. after hosted config fetch).
+ */
+@Singleton
+class SyncRetrofitHolder @Inject constructor(
+ private val store: RemoteEndpointStore,
+ private val httpClient: OkHttpClient
+) {
+ private val lock = Any()
+ private var cachedBaseUrl: String? = null
+ private var retrofit: Retrofit? = null
+
+ /** True when the app may use HTTP sync (static URL, hosted config URL, or cached override). */
+ fun shouldEnableBackendNetworking(): Boolean {
+ if (BuildConfig.SMOA_BACKEND_BASE_URL.isNotBlank()) return true
+ if (BuildConfig.SMOA_CONFIG_URL.isNotBlank()) return true
+ if (store.getBackendBaseUrl()?.isNotBlank() == true) return true
+ return false
+ }
+
+ fun getSyncApiOrNull(): Pair? {
+ val r = retrofitOrNull() ?: return null
+ return Pair(r.create(BackendSyncApiService::class.java), effectiveApiKey())
+ }
+
+ fun getPullApiOrNull(): Pair? {
+ val r = retrofitOrNull() ?: return null
+ return Pair(r.create(BackendPullApiService::class.java), effectiveApiKey())
+ }
+
+ private fun retrofitOrNull(): Retrofit? {
+ val base = effectiveBaseUrl() ?: return null
+ synchronized(lock) {
+ if (cachedBaseUrl != base || retrofit == null) {
+ val built = try {
+ Retrofit.Builder()
+ .baseUrl(base)
+ .client(httpClient)
+ .addConverterFactory(GsonConverterFactory.create())
+ .build()
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ if (built != null) {
+ retrofit = built
+ cachedBaseUrl = base
+ } else {
+ retrofit = null
+ cachedBaseUrl = null
+ }
+ }
+ return retrofit
+ }
+ }
+
+ private fun effectiveBaseUrl(): String? {
+ val fromStore = store.getBackendBaseUrl()?.trim()?.takeIf { it.isNotEmpty() }
+ val raw = (fromStore ?: BuildConfig.SMOA_BACKEND_BASE_URL.trim())
+ if (raw.isEmpty()) return null
+ return if (raw.endsWith("/")) raw else "$raw/"
+ }
+
+ private fun effectiveApiKey(): String? =
+ store.getApiKey()?.trim()?.takeIf { it.isNotEmpty() }
+ ?: BuildConfig.SMOA_API_KEY.trim().takeIf { it.isNotEmpty() }
+}
diff --git a/app/src/main/java/com/smoa/auth/AuthPreferencesStore.kt b/app/src/main/java/com/smoa/auth/AuthPreferencesStore.kt
new file mode 100644
index 0000000..60b4cd5
--- /dev/null
+++ b/app/src/main/java/com/smoa/auth/AuthPreferencesStore.kt
@@ -0,0 +1,41 @@
+package com.smoa.auth
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Persists onboarding and lightweight auth bootstrap flags (encrypted at rest).
+ */
+@Singleton
+class AuthPreferencesStore @Inject constructor(
+ @ApplicationContext context: Context
+) {
+ private val prefs by lazy {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ EncryptedSharedPreferences.create(
+ context,
+ PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ fun isOnboardingComplete(): Boolean =
+ prefs.getBoolean(KEY_ONBOARDING_DONE, false)
+
+ fun setOnboardingComplete() {
+ prefs.edit().putBoolean(KEY_ONBOARDING_DONE, true).apply()
+ }
+
+ companion object {
+ private const val PREFS_NAME = "smoa_auth_bootstrap"
+ private const val KEY_ONBOARDING_DONE = "onboarding_complete"
+ }
+}
diff --git a/app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt b/app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt
new file mode 100644
index 0000000..56f7441
--- /dev/null
+++ b/app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt
@@ -0,0 +1,133 @@
+package com.smoa.auth
+
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentActivity
+
+/**
+ * Runs [BiometricPrompt] for mandatory biometric verification during sign-in,
+ * or biometric **or** device credential (PIN/pattern) when [useDeviceCredentialFallback] is true.
+ */
+class BiometricAuthenticator(
+ private val activity: FragmentActivity
+) {
+
+ fun canAuthenticateWithBiometrics(): Int {
+ return BiometricManager.from(activity).canAuthenticate(
+ BiometricManager.Authenticators.BIOMETRIC_STRONG or
+ BiometricManager.Authenticators.BIOMETRIC_WEAK
+ )
+ }
+
+ fun canAuthenticateWithDeviceCredential(): Int {
+ return BiometricManager.from(activity).canAuthenticate(
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL
+ )
+ }
+
+ fun authenticate(
+ onSuccess: () -> Unit,
+ onError: (message: String) -> Unit,
+ onCancelled: () -> Unit,
+ useDeviceCredentialFallback: Boolean = false
+ ) {
+ val combined = BiometricManager.Authenticators.BIOMETRIC_STRONG or
+ BiometricManager.Authenticators.BIOMETRIC_WEAK or
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL
+ val biometricOnly = BiometricManager.Authenticators.BIOMETRIC_STRONG or
+ BiometricManager.Authenticators.BIOMETRIC_WEAK
+ val allowed = if (useDeviceCredentialFallback) combined else biometricOnly
+ val executor = ContextCompat.getMainExecutor(activity)
+ val prompt = BiometricPrompt(
+ activity,
+ executor,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ onSuccess()
+ }
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ when (errorCode) {
+ BiometricPrompt.ERROR_USER_CANCELED,
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> onCancelled()
+ else -> onError(errString.toString())
+ }
+ }
+
+ override fun onAuthenticationFailed() {
+ onError(activity.getString(com.smoa.R.string.auth_biometric_failed))
+ }
+ }
+ )
+ val infoBuilder = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(activity.getString(com.smoa.R.string.auth_biometric_required))
+ .setSubtitle(activity.getString(com.smoa.R.string.auth_biometric_subtitle))
+ .setAllowedAuthenticators(allowed)
+ if (!useDeviceCredentialFallback) {
+ infoBuilder.setNegativeButtonText(activity.getString(com.smoa.R.string.cancel))
+ }
+ prompt.authenticate(infoBuilder.build())
+ }
+
+ /**
+ * Session unlock: PIN/pattern or biometric (no negative button — required by platform when device credential is allowed).
+ */
+ fun authenticateForSessionUnlock(
+ onSuccess: () -> Unit,
+ onError: (message: String) -> Unit,
+ onCancelled: () -> Unit
+ ) {
+ authenticate(
+ onSuccess = onSuccess,
+ onError = onError,
+ onCancelled = onCancelled,
+ useDeviceCredentialFallback = true
+ )
+ }
+
+ fun authenticateWithCryptoObject(
+ cryptoObject: BiometricPrompt.CryptoObject,
+ onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit,
+ onError: (message: String) -> Unit,
+ onCancelled: () -> Unit,
+ useDeviceCredentialFallback: Boolean = false
+ ) {
+ val combined = BiometricManager.Authenticators.BIOMETRIC_STRONG or
+ BiometricManager.Authenticators.BIOMETRIC_WEAK or
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL
+ val biometricOnly = BiometricManager.Authenticators.BIOMETRIC_STRONG or
+ BiometricManager.Authenticators.BIOMETRIC_WEAK
+ val allowed = if (useDeviceCredentialFallback) combined else biometricOnly
+ val executor = ContextCompat.getMainExecutor(activity)
+ val prompt = BiometricPrompt(
+ activity,
+ executor,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ onSuccess(result)
+ }
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ when (errorCode) {
+ BiometricPrompt.ERROR_USER_CANCELED,
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON -> onCancelled()
+ else -> onError(errString.toString())
+ }
+ }
+
+ override fun onAuthenticationFailed() {
+ onError(activity.getString(com.smoa.R.string.auth_biometric_failed))
+ }
+ }
+ )
+ val infoBuilder = BiometricPrompt.PromptInfo.Builder()
+ .setTitle(activity.getString(com.smoa.R.string.auth_biometric_required))
+ .setSubtitle(activity.getString(com.smoa.R.string.auth_biometric_subtitle))
+ .setAllowedAuthenticators(allowed)
+ if (!useDeviceCredentialFallback) {
+ infoBuilder.setNegativeButtonText(activity.getString(com.smoa.R.string.cancel))
+ }
+ prompt.authenticate(infoBuilder.build(), cryptoObject)
+ }
+}
diff --git a/app/src/main/java/com/smoa/config/ClientConfigFetcher.kt b/app/src/main/java/com/smoa/config/ClientConfigFetcher.kt
new file mode 100644
index 0000000..0cf2d1f
--- /dev/null
+++ b/app/src/main/java/com/smoa/config/ClientConfigFetcher.kt
@@ -0,0 +1,29 @@
+package com.smoa.config
+
+import com.google.gson.Gson
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ClientConfigFetcher @Inject constructor(
+ private val httpClient: OkHttpClient
+) {
+ private val gson = Gson()
+
+ suspend fun fetch(configUrl: String): SmoaClientConfigJson = withContext(Dispatchers.IO) {
+ val req = Request.Builder().url(configUrl).get().build()
+ httpClient.newCall(req).execute().use { resp ->
+ if (!resp.isSuccessful) {
+ throw IOException("Client config HTTP ${resp.code}")
+ }
+ val body = resp.body?.string() ?: throw IOException("Client config empty body")
+ gson.fromJson(body, SmoaClientConfigJson::class.java)
+ ?: throw IOException("Client config JSON parse failed")
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/config/ClientConfigRefreshCoordinator.kt b/app/src/main/java/com/smoa/config/ClientConfigRefreshCoordinator.kt
new file mode 100644
index 0000000..aaac2c9
--- /dev/null
+++ b/app/src/main/java/com/smoa/config/ClientConfigRefreshCoordinator.kt
@@ -0,0 +1,49 @@
+package com.smoa.config
+
+import com.smoa.BuildConfig
+import com.smoa.modules.communications.domain.InfrastructureManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeout
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Fetches hosted client config without blocking the main thread.
+ * [com.smoa.api.SyncRetrofitHolder] picks up new URLs from [RemoteEndpointStore] on the next HTTP call.
+ */
+@Singleton
+class ClientConfigRefreshCoordinator @Inject constructor(
+ private val fetcher: ClientConfigFetcher,
+ private val store: RemoteEndpointStore,
+ private val infrastructureManager: InfrastructureManager
+) {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ fun scheduleNonBlockingInitialLoad(timeoutMs: Long = 5_000L) {
+ val url = BuildConfig.SMOA_CONFIG_URL.trim()
+ if (url.isEmpty()) return
+ scope.launch {
+ runCatching {
+ withTimeout(timeoutMs) {
+ fetcher.fetch(url)
+ }
+ }.onSuccess { cfg ->
+ store.applyRemoteConfig(cfg)
+ applyInfrastructure(cfg)
+ }
+ }
+ }
+
+ private fun applyInfrastructure(cfg: SmoaClientConfigJson) {
+ cfg.stunUrls?.splitCommaList()?.takeIf { it.isNotEmpty() }
+ ?.let { infrastructureManager.setStunEndpoints(it) }
+ cfg.signalingUrls?.splitCommaList()?.takeIf { it.isNotEmpty() }
+ ?.let { infrastructureManager.setSignalingEndpoints(it) }
+ }
+
+ private fun String.splitCommaList(): List =
+ split(',').map { it.trim() }.filter { it.isNotEmpty() }
+}
diff --git a/app/src/main/java/com/smoa/config/RemoteEndpointStore.kt b/app/src/main/java/com/smoa/config/RemoteEndpointStore.kt
new file mode 100644
index 0000000..7849f42
--- /dev/null
+++ b/app/src/main/java/com/smoa/config/RemoteEndpointStore.kt
@@ -0,0 +1,72 @@
+package com.smoa.config
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Persists optional overrides from hosted [SmoaClientConfigJson]; [com.smoa.di.AppModule] merges with [com.smoa.BuildConfig].
+ */
+@Singleton
+class RemoteEndpointStore @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val _configEpoch = MutableStateFlow(0L)
+ val configEpoch: StateFlow = _configEpoch.asStateFlow()
+
+ private val prefs: SharedPreferences by lazy {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ EncryptedSharedPreferences.create(
+ context,
+ PREFS_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ fun getBackendBaseUrl(): String? = prefs.getString(KEY_BACKEND, null)
+
+ fun getApiKey(): String? = prefs.getString(KEY_API_KEY, null)
+
+ fun getTlsPinSpec(): String? = prefs.getString(KEY_TLS_PIN_SPEC, null)
+
+ fun getClassificationWatermarkPrimary(): String? =
+ prefs.getString(KEY_CLASS_PRIMARY, null)?.trim()?.takeIf { it.isNotEmpty() }
+
+ fun getClassificationWatermarkSecondary(): String? =
+ prefs.getString(KEY_CLASS_SECONDARY, null)?.trim()?.takeIf { it.isNotEmpty() }
+
+ fun applyRemoteConfig(cfg: SmoaClientConfigJson) {
+ val e = prefs.edit()
+ cfg.apiBaseUrl?.trim()?.takeIf { it.isNotEmpty() }?.let { e.putString(KEY_BACKEND, it) }
+ cfg.apiKey?.trim()?.takeIf { it.isNotEmpty() }?.let { e.putString(KEY_API_KEY, it) }
+ cfg.tlsPinSpec?.trim()?.takeIf { it.isNotEmpty() }?.let { e.putString(KEY_TLS_PIN_SPEC, it) }
+ cfg.classificationWatermarkPrimary?.trim()?.takeIf { it.isNotEmpty() }
+ ?.let { e.putString(KEY_CLASS_PRIMARY, it) }
+ cfg.classificationWatermarkSecondary?.trim()?.takeIf { it.isNotEmpty() }
+ ?.let { e.putString(KEY_CLASS_SECONDARY, it) }
+ e.putLong(KEY_UPDATED_AT, System.currentTimeMillis())
+ e.apply()
+ _configEpoch.value = System.currentTimeMillis()
+ }
+
+ companion object {
+ private const val PREFS_NAME = "smoa_remote_endpoints"
+ private const val KEY_BACKEND = "backend_base_url"
+ private const val KEY_API_KEY = "api_key"
+ private const val KEY_UPDATED_AT = "config_updated_at"
+ private const val KEY_TLS_PIN_SPEC = "tls_pin_spec"
+ private const val KEY_CLASS_PRIMARY = "classification_watermark_primary"
+ private const val KEY_CLASS_SECONDARY = "classification_watermark_secondary"
+ }
+}
diff --git a/app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt b/app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt
new file mode 100644
index 0000000..464c077
--- /dev/null
+++ b/app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt
@@ -0,0 +1,22 @@
+package com.smoa.config
+
+import com.google.gson.annotations.SerializedName
+
+/**
+ * Parsed body of hosted client config JSON (see `backend/docs/examples/smoa-client-config.example.json`).
+ */
+data class SmoaClientConfigJson(
+ @SerializedName("schema_version") val schemaVersion: String? = null,
+ @SerializedName("environment") val environment: String? = null,
+ @SerializedName("api_base_url") val apiBaseUrl: String? = null,
+ @SerializedName("api_key") val apiKey: String? = null,
+ @SerializedName("stun_urls") val stunUrls: String? = null,
+ @SerializedName("signaling_urls") val signalingUrls: String? = null,
+ @SerializedName("turn_urls") val turnUrls: String? = null,
+ @SerializedName("well_known_health_url") val wellKnownHealthUrl: String? = null,
+ @SerializedName("openapi_url") val openapiUrl: String? = null,
+ /** Optional multi-host pin spec: `host|sha256/a,sha256/b;host2|sha256/c` (see [com.smoa.network.CertificatePinnerFactory]). */
+ @SerializedName("tls_pin_spec") val tlsPinSpec: String? = null,
+ @SerializedName("classification_watermark_primary") val classificationWatermarkPrimary: String? = null,
+ @SerializedName("classification_watermark_secondary") val classificationWatermarkSecondary: String? = null
+)
diff --git a/app/src/main/java/com/smoa/di/AppModule.kt b/app/src/main/java/com/smoa/di/AppModule.kt
index 903e1bf..f5adf67 100644
--- a/app/src/main/java/com/smoa/di/AppModule.kt
+++ b/app/src/main/java/com/smoa/di/AppModule.kt
@@ -1,9 +1,8 @@
package com.smoa.di
import com.smoa.api.BackendPullAPI
-import com.smoa.api.BackendPullApiService
import com.smoa.api.BackendSyncAPI
-import com.smoa.api.BackendSyncApiService
+import com.smoa.api.SyncRetrofitHolder
import com.smoa.core.common.DefaultPullAPI
import com.smoa.core.common.DefaultSyncAPI
import com.smoa.core.common.PullAPI
@@ -12,16 +11,14 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
-import okhttp3.OkHttpClient
-import retrofit2.Retrofit
-import retrofit2.converter.gson.GsonConverterFactory
-import java.util.concurrent.TimeUnit
import javax.inject.Singleton
/**
* App-level bindings. Provides SyncAPI and PullAPI for SyncService.
- * When SMOA_BACKEND_BASE_URL is set, backend implementations are used.
+ * When backend networking is enabled (see [SyncRetrofitHolder.shouldEnableBackendNetworking]), uses [SyncRetrofitHolder]
+ * for a shared [okhttp3.OkHttpClient] and Retrofit instances that refresh when the effective base URL changes.
* Build with -Psmoa.backend.baseUrl=http://10.0.2.2:8080/ and -Psmoa.api.key=key for emulator.
+ * Optional: -Psmoa.config.url=https://config.example.com/smoa/client-config.json
*/
@Module
@InstallIn(SingletonComponent::class)
@@ -29,35 +26,15 @@ object AppModule {
@Provides
@Singleton
- fun provideSyncAPI(): SyncAPI {
- val (baseUrl, apiKey, retrofit) = createRetrofitIfConfigured() ?: return DefaultSyncAPI()
- val service = retrofit.create(BackendSyncApiService::class.java)
- return BackendSyncAPI(baseUrl, service, apiKey = apiKey)
+ fun provideSyncAPI(holder: SyncRetrofitHolder): SyncAPI {
+ if (!holder.shouldEnableBackendNetworking()) return DefaultSyncAPI()
+ return BackendSyncAPI { holder.getSyncApiOrNull() }
}
@Provides
@Singleton
- fun providePullAPI(): PullAPI {
- val (_, apiKey, retrofit) = createRetrofitIfConfigured() ?: return DefaultPullAPI()
- val service = retrofit.create(BackendPullApiService::class.java)
- return BackendPullAPI(service, apiKey = apiKey)
- }
-
- private fun createRetrofitIfConfigured(): Triple? {
- val raw = com.smoa.BuildConfig.SMOA_BACKEND_BASE_URL.trim()
- val baseUrl = if (raw.isEmpty()) null else (if (raw.endsWith("/")) raw else "$raw/")
- val apiKey = com.smoa.BuildConfig.SMOA_API_KEY?.trim()?.takeIf { it.isNotEmpty() }
- if (baseUrl == null) return null
- val client = OkHttpClient.Builder()
- .connectTimeout(30, TimeUnit.SECONDS)
- .readTimeout(30, TimeUnit.SECONDS)
- .writeTimeout(30, TimeUnit.SECONDS)
- .build()
- val retrofit = Retrofit.Builder()
- .baseUrl(baseUrl)
- .client(client)
- .addConverterFactory(GsonConverterFactory.create())
- .build()
- return Triple(baseUrl, apiKey, retrofit)
+ fun providePullAPI(holder: SyncRetrofitHolder): PullAPI {
+ if (!holder.shouldEnableBackendNetworking()) return DefaultPullAPI()
+ return BackendPullAPI { holder.getPullApiOrNull() }
}
}
diff --git a/app/src/main/java/com/smoa/di/NetworkModule.kt b/app/src/main/java/com/smoa/di/NetworkModule.kt
new file mode 100644
index 0000000..2dc60b8
--- /dev/null
+++ b/app/src/main/java/com/smoa/di/NetworkModule.kt
@@ -0,0 +1,57 @@
+package com.smoa.di
+
+import com.smoa.BuildConfig
+import com.smoa.config.RemoteEndpointStore
+import com.smoa.network.AuthTokenInterceptor
+import com.smoa.network.CertificatePinnerFactory
+import com.smoa.network.NetworkPinningConfig
+import com.smoa.network.TokenRefreshAuthenticator
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import java.util.concurrent.TimeUnit
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ @Named("unauthenticated")
+ fun provideUnauthenticatedOkHttpClient(): OkHttpClient {
+ return OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(
+ authTokenInterceptor: AuthTokenInterceptor,
+ tokenRefreshAuthenticator: TokenRefreshAuthenticator,
+ remoteEndpointStore: RemoteEndpointStore
+ ): OkHttpClient {
+ val builder = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .addInterceptor(authTokenInterceptor)
+ .authenticator(tokenRefreshAuthenticator)
+ val pinSpec = remoteEndpointStore.getTlsPinSpec()?.trim()?.takeIf { it.isNotEmpty() }
+ ?: BuildConfig.SMOA_TLS_PIN_SPEC.trim().takeIf { it.isNotEmpty() }
+ val pinner = if (!pinSpec.isNullOrEmpty()) {
+ CertificatePinnerFactory.buildFromMultiSpec(pinSpec)
+ } else {
+ val host = NetworkPinningConfig.resolveBackendHost(remoteEndpointStore)
+ CertificatePinnerFactory.build(host, BuildConfig.SMOA_TLS_PINS)
+ }
+ pinner?.let { builder.certificatePinner(it) }
+ return builder.build()
+ }
+}
diff --git a/app/src/main/java/com/smoa/di/SmoaRemoteConfigEntryPoint.kt b/app/src/main/java/com/smoa/di/SmoaRemoteConfigEntryPoint.kt
new file mode 100644
index 0000000..e8b19f7
--- /dev/null
+++ b/app/src/main/java/com/smoa/di/SmoaRemoteConfigEntryPoint.kt
@@ -0,0 +1,12 @@
+package com.smoa.di
+
+import com.smoa.config.RemoteEndpointStore
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface SmoaRemoteConfigEntryPoint {
+ fun remoteEndpointStore(): RemoteEndpointStore
+}
diff --git a/app/src/main/java/com/smoa/di/SyncRemoteDataModule.kt b/app/src/main/java/com/smoa/di/SyncRemoteDataModule.kt
new file mode 100644
index 0000000..f3ba7e5
--- /dev/null
+++ b/app/src/main/java/com/smoa/di/SyncRemoteDataModule.kt
@@ -0,0 +1,48 @@
+package com.smoa.di
+
+import android.content.Context
+import androidx.room.Room
+import com.smoa.core.common.sync.SyncRemoteDataCachePort
+import com.smoa.core.security.EncryptedDatabaseHelper
+import com.smoa.modules.credentials.data.CredentialCacheMerger
+import com.smoa.sync.CompositeSyncRemoteDataCache
+import com.smoa.sync.SyncConflictSnapshotDao
+import com.smoa.sync.SyncConflictSnapshotDatabase
+import com.smoa.sync.SyncConflictSnapshotStore
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object SyncRemoteDataModule {
+
+ @Provides
+ @Singleton
+ fun provideSyncConflictSnapshotDatabase(
+ @ApplicationContext context: Context,
+ encryptedDatabaseHelper: EncryptedDatabaseHelper
+ ): SyncConflictSnapshotDatabase {
+ val factory = encryptedDatabaseHelper.createOpenHelperFactory("sync_conflict_snapshot_db")
+ return Room.databaseBuilder(
+ context,
+ SyncConflictSnapshotDatabase::class.java,
+ "sync_conflict_snapshot_db"
+ )
+ .openHelperFactory(factory)
+ .build()
+ }
+
+ @Provides
+ fun provideSyncConflictSnapshotDao(db: SyncConflictSnapshotDatabase): SyncConflictSnapshotDao = db.dao()
+
+ @Provides
+ @Singleton
+ fun provideSyncRemoteDataCachePort(
+ merger: CredentialCacheMerger,
+ snapshots: SyncConflictSnapshotStore
+ ): SyncRemoteDataCachePort = CompositeSyncRemoteDataCache(merger, snapshots)
+}
diff --git a/app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt b/app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt
new file mode 100644
index 0000000..b423b27
--- /dev/null
+++ b/app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt
@@ -0,0 +1,24 @@
+package com.smoa.network
+
+import com.smoa.security.SecureTokenStore
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+
+/**
+ * Attaches `Authorization: Bearer` when an OIDC/access token is present in [SecureTokenStore].
+ * API-key flows (X-API-Key) remain unchanged when no bearer token is stored.
+ */
+class AuthTokenInterceptor @Inject constructor(
+ private val secureTokenStore: SecureTokenStore
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val token = secureTokenStore.peekAccessToken()?.trim()?.takeIf { it.isNotEmpty() }
+ ?: return chain.proceed(chain.request())
+ val request = chain.request().newBuilder()
+ .header("Authorization", "Bearer $token")
+ .build()
+ return chain.proceed(request)
+ }
+}
diff --git a/app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt b/app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt
new file mode 100644
index 0000000..9f8cebd
--- /dev/null
+++ b/app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt
@@ -0,0 +1,53 @@
+package com.smoa.network
+
+import okhttp3.CertificatePinner
+
+/**
+ * Builds OkHttp [CertificatePinner] from comma-separated SHA-256 pins (`sha256/...` base64)
+ * for a single host, or from a multi-host spec.
+ *
+ * Multi-host format (semicolon separates hosts): `hostname|sha256/a,sha256/b;other.host|sha256/c`
+ */
+object CertificatePinnerFactory {
+
+ fun build(hostname: String?, pinsCsv: String): CertificatePinner? {
+ val host = hostname?.trim()?.takeIf { it.isNotEmpty() } ?: return null
+ val pins = parsePinList(pinsCsv) ?: return null
+ return buildForHostPins(mapOf(host to pins))
+ }
+
+ /**
+ * Parses [spec] as `host|pin1,pin2;host2|pin3` (pins are comma-separated, hosts are semicolon-separated).
+ */
+ fun buildFromMultiSpec(spec: String): CertificatePinner? {
+ val trimmed = spec.trim()
+ if (trimmed.isEmpty()) return null
+ val segments = trimmed.split(';').map { it.trim() }.filter { it.isNotEmpty() }
+ val map = linkedMapOf>()
+ for (seg in segments) {
+ val pipe = seg.indexOf('|')
+ if (pipe <= 0 || pipe >= seg.lastIndex) return null
+ val host = seg.substring(0, pipe).trim().lowercase()
+ val pinPart = seg.substring(pipe + 1)
+ val pins = parsePinList(pinPart) ?: return null
+ map[host] = pins
+ }
+ if (map.isEmpty()) return null
+ return buildForHostPins(map)
+ }
+
+ private fun parsePinList(pinsCsv: String): List? {
+ val pins = pinsCsv.split(',')
+ .map { it.trim() }
+ .filter { it.startsWith("sha256/") && it.length > 10 }
+ return pins.takeIf { it.isNotEmpty() }
+ }
+
+ private fun buildForHostPins(hostToPins: Map>): CertificatePinner {
+ val builder = CertificatePinner.Builder()
+ hostToPins.forEach { (host, pins) ->
+ pins.forEach { pin -> builder.add(host, pin) }
+ }
+ return builder.build()
+ }
+}
diff --git a/app/src/main/java/com/smoa/network/NetworkPinningConfig.kt b/app/src/main/java/com/smoa/network/NetworkPinningConfig.kt
new file mode 100644
index 0000000..ff84166
--- /dev/null
+++ b/app/src/main/java/com/smoa/network/NetworkPinningConfig.kt
@@ -0,0 +1,31 @@
+package com.smoa.network
+
+import android.net.Uri
+import com.smoa.BuildConfig
+import com.smoa.config.RemoteEndpointStore
+
+/**
+ * Resolves the API host used for [okhttp3.CertificatePinner] from [RemoteEndpointStore] (after hosted config)
+ * or [BuildConfig.SMOA_BACKEND_BASE_URL].
+ */
+object NetworkPinningConfig {
+
+ fun resolveBackendHost(store: RemoteEndpointStore): String? {
+ store.getBackendBaseUrl()?.trim()?.takeIf { it.isNotEmpty() }?.let { url ->
+ hostFromUrl(url)?.let { return it }
+ }
+ return resolveBackendHostFromBuildConfig()
+ }
+
+ fun resolveBackendHostFromBuildConfig(): String? {
+ val raw = BuildConfig.SMOA_BACKEND_BASE_URL.trim()
+ if (raw.isEmpty()) return null
+ return hostFromUrl(raw)
+ }
+
+ private fun hostFromUrl(raw: String): String? = try {
+ Uri.parse(raw).host?.lowercase()
+ } catch (_: Exception) {
+ null
+ }
+}
diff --git a/app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt b/app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt
new file mode 100644
index 0000000..6ba4604
--- /dev/null
+++ b/app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt
@@ -0,0 +1,99 @@
+package com.smoa.network
+
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+import com.smoa.security.OidcBootstrap
+import com.smoa.security.SecureTokenStore
+import okhttp3.Authenticator
+import okhttp3.FormBody
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import javax.inject.Inject
+import javax.inject.Named
+import javax.inject.Singleton
+
+private const val RETRY_HEADER = "X-Smoa-Auth-Retry"
+
+private data class RefreshTokenJsonResponse(
+ @SerializedName("access_token") val accessToken: String?,
+ @SerializedName("refresh_token") val refreshToken: String?,
+ @SerializedName("expires_in") val expiresIn: Long?
+)
+
+/**
+ * On HTTP 401, exchanges [SecureTokenStore] refresh token at the OIDC token endpoint (no interceptor loop).
+ */
+@Singleton
+class TokenRefreshAuthenticator @Inject constructor(
+ private val secureTokenStore: SecureTokenStore,
+ @Named("unauthenticated") private val rawClient: OkHttpClient
+) : Authenticator {
+
+ private val gson = Gson()
+ private val refreshLock = Any()
+
+ override fun authenticate(route: Route?, response: Response): Request? {
+ if (response.request.header(RETRY_HEADER) != null) return null
+ if (responseCount(response) > 2) return null
+ val path = response.request.url.encodedPath
+ if (path.contains("/token") || path.endsWith("/oauth/token")) return null
+
+ val refresh = secureTokenStore.peekRefreshToken()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
+ val tokenEndpoint = secureTokenStore.peekTokenEndpoint()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
+ val clientId = OidcBootstrap.clientId().ifEmpty { secureTokenStore.peekOidcClientId() ?: "" }
+ if (clientId.isEmpty()) return null
+
+ synchronized(refreshLock) {
+ val bearer = response.request.header("Authorization")
+ val failedAccess = bearer?.removePrefix("Bearer ")?.trim()
+ val current = secureTokenStore.peekAccessToken()
+ if (failedAccess != null && current != null && current != failedAccess) {
+ return response.request.newBuilder()
+ .removeHeader("Authorization")
+ .header(RETRY_HEADER, "1")
+ .build()
+ }
+ val newTokens = runCatching { performRefresh(tokenEndpoint, clientId, refresh) }.getOrNull()
+ ?: return null
+ val access = newTokens.accessToken ?: return null
+ val exp = newTokens.expiresIn?.let { System.currentTimeMillis() / 1000 + it }
+ secureTokenStore.persistTokens(
+ accessToken = access,
+ refreshToken = newTokens.refreshToken ?: refresh,
+ idToken = secureTokenStore.peekIdToken(),
+ expiresAtEpochSeconds = exp
+ )
+ }
+
+ return response.request.newBuilder()
+ .header(RETRY_HEADER, "1")
+ .removeHeader("Authorization")
+ .build()
+ }
+
+ private fun performRefresh(tokenEndpoint: String, clientId: String, refreshToken: String): RefreshTokenJsonResponse? {
+ val body = FormBody.Builder()
+ .add("grant_type", "refresh_token")
+ .add("refresh_token", refreshToken)
+ .add("client_id", clientId)
+ .build()
+ val req = Request.Builder().url(tokenEndpoint).post(body).build()
+ rawClient.newCall(req).execute().use { resp ->
+ val text = resp.body?.string() ?: return null
+ if (!resp.isSuccessful) return null
+ return gson.fromJson(text, RefreshTokenJsonResponse::class.java)
+ }
+ }
+
+ private fun responseCount(response: Response): Int {
+ var n = 1
+ var p = response.priorResponse
+ while (p != null) {
+ n++
+ p = p.priorResponse
+ }
+ return n
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/BiometricSecretsVault.kt b/app/src/main/java/com/smoa/security/BiometricSecretsVault.kt
new file mode 100644
index 0000000..ad39a00
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/BiometricSecretsVault.kt
@@ -0,0 +1,66 @@
+package com.smoa.security
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import androidx.biometric.BiometricPrompt
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Hardware-backed AES key marked **user authentication required** for wrapping high-value secrets
+ * (e.g. refresh token) with biometric / device credential gates.
+ *
+ * Use [tryCreateEncryptCryptoObject] with [androidx.biometric.BiometricPrompt.authenticate] to gate
+ * encrypt/decrypt (e.g. wrapping a refresh token) behind the same user authentication required by the key.
+ */
+@Singleton
+class BiometricSecretsVault @Inject constructor() {
+
+ private val keyStore: KeyStore by lazy {
+ KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
+ }
+
+ fun hasKey(): Boolean = keyStore.containsAlias(KEY_ALIAS)
+
+ fun getOrCreateSecretKey(): SecretKey {
+ if (hasKey()) {
+ return (keyStore.getEntry(KEY_ALIAS, null) as KeyStore.SecretKeyEntry).secretKey
+ }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
+ val spec = KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setUserAuthenticationRequired(true)
+ .setInvalidatedByBiometricEnrollment(true)
+ .build()
+ generator.init(spec)
+ return generator.generateKey()
+ }
+
+ /**
+ * Prepares AES-GCM encrypt [BiometricPrompt.CryptoObject] for the keystore key (user auth required).
+ * Returns null if the cipher cannot be initialized (e.g. key needs enrollment).
+ */
+ fun tryCreateEncryptCryptoObject(): BiometricPrompt.CryptoObject? {
+ return try {
+ val key = getOrCreateSecretKey()
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding")
+ cipher.init(Cipher.ENCRYPT_MODE, key)
+ BiometricPrompt.CryptoObject(cipher)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ companion object {
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
+ private const val KEY_ALIAS = "smoa_biometric_wrap_aes"
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt b/app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt
new file mode 100644
index 0000000..460a114
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt
@@ -0,0 +1,25 @@
+package com.smoa.security
+
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Best-effort detection of Samsung Knox / enterprise SDK on classpath without bundling proprietary AARs.
+ * For full MDM policies, integrate Samsung Knox SDK or your UEM vendor agent per deployment guide.
+ */
+@Singleton
+class KnoxEnterpriseProbe @Inject constructor() {
+
+ fun isKnoxSdkOnClasspath(): Boolean = try {
+ Class.forName("com.samsung.android.knox.EnterpriseDeviceManager")
+ true
+ } catch (_: ClassNotFoundException) {
+ false
+ }
+
+ fun summary(): String = if (isKnoxSdkOnClasspath()) {
+ "Knox EnterpriseDeviceManager class found (device/agent may expose Knox APIs)."
+ } else {
+ "Knox SDK classes not on classpath (typical for generic AOSP / non-Samsung builds)."
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/OidcAuthStateHolder.kt b/app/src/main/java/com/smoa/security/OidcAuthStateHolder.kt
new file mode 100644
index 0000000..7c5271c
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/OidcAuthStateHolder.kt
@@ -0,0 +1,29 @@
+package com.smoa.security
+
+import net.openid.appauth.AuthorizationServiceConfiguration
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class OidcAuthStateHolder @Inject constructor() {
+
+ @Volatile
+ private var pending: AuthorizationServiceConfiguration? = null
+
+ @Synchronized
+ fun putConfiguration(configuration: AuthorizationServiceConfiguration) {
+ pending = configuration
+ }
+
+ @Synchronized
+ fun takeConfiguration(): AuthorizationServiceConfiguration? {
+ val c = pending
+ pending = null
+ return c
+ }
+
+ @Synchronized
+ fun clear() {
+ pending = null
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/OidcBootstrap.kt b/app/src/main/java/com/smoa/security/OidcBootstrap.kt
new file mode 100644
index 0000000..9131abc
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/OidcBootstrap.kt
@@ -0,0 +1,24 @@
+package com.smoa.security
+
+import com.smoa.BuildConfig
+
+/**
+ * Build-time OIDC/OAuth hints for IdP integration (AppAuth, Custom Tabs, or enterprise SSO WebView).
+ */
+object OidcBootstrap {
+
+ fun issuer(): String = BuildConfig.SMOA_OIDC_ISSUER.trim()
+
+ fun clientId(): String = BuildConfig.SMOA_OIDC_CLIENT_ID.trim()
+
+ fun redirectUri(): String = BuildConfig.SMOA_OIDC_REDIRECT_URI.trim()
+
+ fun discoveryUrl(): String? {
+ val iss = issuer()
+ if (iss.isEmpty()) return null
+ val base = iss.trimEnd('/')
+ return "$base/.well-known/openid-configuration"
+ }
+
+ fun isConfigured(): Boolean = issuer().isNotEmpty() && clientId().isNotEmpty() && redirectUri().isNotEmpty()
+}
diff --git a/app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt b/app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt
new file mode 100644
index 0000000..86a5eed
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt
@@ -0,0 +1,186 @@
+package com.smoa.security
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Base64
+import androidx.fragment.app.FragmentActivity
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import com.smoa.core.auth.RBACFramework
+import com.smoa.core.auth.UserInfo
+import com.smoa.core.auth.UserSession
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import net.openid.appauth.AuthorizationException
+import net.openid.appauth.AuthorizationRequest
+import net.openid.appauth.AuthorizationResponse
+import net.openid.appauth.AuthorizationService
+import net.openid.appauth.AuthorizationServiceConfiguration
+import net.openid.appauth.ResponseTypeValues
+import net.openid.appauth.TokenResponse
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+@Singleton
+class OidcLoginCoordinator @Inject constructor(
+ @ApplicationContext private val appContext: Context,
+ private val oidcAuthStateHolder: OidcAuthStateHolder,
+ private val secureTokenStore: SecureTokenStore,
+ private val userSession: UserSession
+) {
+
+ private val authServiceLock = Any()
+ private var pendingAuthService: AuthorizationService? = null
+
+ fun cancelPendingFlow() {
+ synchronized(authServiceLock) {
+ pendingAuthService?.dispose()
+ pendingAuthService = null
+ }
+ oidcAuthStateHolder.clear()
+ }
+
+ suspend fun prepareAuthorizationIntent(): Intent {
+ if (!OidcBootstrap.isConfigured()) {
+ error("OIDC is not configured (issuer, client id, redirect URI).")
+ }
+ val config = withContext(Dispatchers.IO) { fetchDiscovery() }
+ return withContext(Dispatchers.Main) {
+ synchronized(authServiceLock) {
+ pendingAuthService?.dispose()
+ val service = AuthorizationService(appContext).also { pendingAuthService = it }
+ oidcAuthStateHolder.putConfiguration(config)
+ val redirect = Uri.parse(OidcBootstrap.redirectUri())
+ val request = AuthorizationRequest.Builder(
+ config,
+ OidcBootstrap.clientId(),
+ ResponseTypeValues.CODE,
+ redirect
+ )
+ .setScope("openid profile email offline_access")
+ .build()
+ service.getAuthorizationRequestIntent(request)
+ }
+ }
+ }
+
+ suspend fun finishAuthorization(@Suppress("UNUSED_PARAMETER") activity: FragmentActivity, data: Intent) =
+ withContext(Dispatchers.IO) {
+ val response = AuthorizationResponse.fromIntent(data)
+ val ex = AuthorizationException.fromIntent(data)
+ if (ex != null) {
+ cancelPendingFlow()
+ throw IllegalStateException(ex.error ?: "authorization_failed")
+ }
+ if (response == null) {
+ cancelPendingFlow()
+ throw IllegalStateException("missing_authorization_response")
+ }
+ val config = oidcAuthStateHolder.takeConfiguration()
+ ?: run {
+ cancelPendingFlow()
+ throw IllegalStateException("missing_oidc_configuration")
+ }
+ val service = synchronized(authServiceLock) {
+ pendingAuthService ?: AuthorizationService(appContext)
+ }
+ try {
+ val tokenResponse = suspendCancellableCoroutine { cont ->
+ val exchange = response.createTokenExchangeRequest()
+ service.performTokenRequest(exchange) { resp: TokenResponse?, tex: AuthorizationException? ->
+ when {
+ resp != null -> cont.resume(resp)
+ tex != null -> cont.resumeWithException(
+ IllegalStateException(tex.error ?: "token_exchange_failed")
+ )
+ else -> cont.resumeWithException(IllegalStateException("token_exchange_failed"))
+ }
+ }
+ }
+ val expSec = tokenResponse.accessTokenExpirationTime
+ secureTokenStore.persistTokens(
+ accessToken = tokenResponse.accessToken,
+ refreshToken = tokenResponse.refreshToken,
+ idToken = tokenResponse.idToken,
+ expiresAtEpochSeconds = expSec
+ )
+ secureTokenStore.persistOidcEndpoints(
+ tokenEndpoint = config.tokenEndpoint.toString(),
+ clientId = OidcBootstrap.clientId().ifEmpty { null }
+ )
+ val claims = parseIdTokenClaims(tokenResponse.idToken)
+ val sub = claims["sub"] ?: "oidc_user"
+ val display = claims["name"] ?: claims["preferred_username"] ?: sub
+ userSession.setUser(
+ UserInfo(
+ userId = sub,
+ userName = display,
+ role = RBACFramework.Role.OPERATOR,
+ unit = "OIDC",
+ clearanceLevel = null,
+ missionAssignment = null
+ )
+ )
+ } finally {
+ synchronized(authServiceLock) {
+ service.dispose()
+ pendingAuthService = null
+ }
+ }
+ }
+
+ private suspend fun fetchDiscovery(): AuthorizationServiceConfiguration =
+ suspendCancellableCoroutine { cont ->
+ val issuer = Uri.parse(OidcBootstrap.issuer())
+ AuthorizationServiceConfiguration.fetchFromIssuer(
+ issuer,
+ object : AuthorizationServiceConfiguration.RetrieveConfigurationCallback {
+ override fun onFetchConfigurationCompleted(
+ configuration: AuthorizationServiceConfiguration?,
+ authorizationException: AuthorizationException?
+ ) {
+ when {
+ configuration != null -> cont.resume(configuration)
+ authorizationException != null ->
+ cont.resumeWithException(
+ IllegalStateException(
+ authorizationException.error ?: "discovery_failed"
+ )
+ )
+ else -> cont.resumeWithException(IllegalStateException("discovery_failed"))
+ }
+ }
+ }
+ )
+ }
+
+ private fun parseIdTokenClaims(idToken: String?): Map {
+ if (idToken.isNullOrEmpty()) return emptyMap()
+ val parts = idToken.split('.')
+ if (parts.size < 2) return emptyMap()
+ return try {
+ val json = String(
+ Base64.decode(parts[1], Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP),
+ Charsets.UTF_8
+ )
+ val obj: JsonObject = JsonParser.parseString(json).asJsonObject
+ obj.keySet().associateWith { key ->
+ val el = obj.get(key) ?: return@associateWith ""
+ when {
+ el.isJsonPrimitive -> {
+ val p = el.asJsonPrimitive
+ if (p.isString) p.asString else p.toString()
+ }
+ else -> el.toString().trim('"')
+ }
+ }
+ } catch (_: Exception) {
+ emptyMap()
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt b/app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt
new file mode 100644
index 0000000..2c636a2
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt
@@ -0,0 +1,49 @@
+package com.smoa.security
+
+import android.content.Context
+import com.google.android.play.core.integrity.IntegrityManagerFactory
+import com.google.android.play.core.integrity.IntegrityTokenRequest
+import com.smoa.BuildConfig
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.tasks.await
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Requests a Play Integrity token when [BuildConfig.SMOA_PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER] is set.
+ * Send the full token to your backend for verification (Google Play Console API).
+ */
+@Singleton
+class PlayIntegrityVerifier @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+
+ suspend fun requestIntegrityTokenOrNull(): String? {
+ val raw = BuildConfig.SMOA_PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER.trim()
+ if (raw.isEmpty()) return null
+ val projectNumber = raw.toLongOrNull() ?: return null
+ return try {
+ val integrityManager = IntegrityManagerFactory.create(context)
+ val nonce = UUID.randomUUID().toString()
+ val request = IntegrityTokenRequest.builder()
+ .setCloudProjectNumber(projectNumber)
+ .setNonce(nonce)
+ .build()
+ val response = integrityManager.requestIntegrityToken(request).await()
+ response.token()
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ suspend fun summarizeForDisplay(): String {
+ val token = requestIntegrityTokenOrNull()
+ ?: return if (BuildConfig.SMOA_PLAY_INTEGRITY_CLOUD_PROJECT_NUMBER.isBlank()) {
+ "Integrity: not configured (set smoa.playIntegrity.cloudProjectNumber)."
+ } else {
+ "Integrity: token request failed (emulator, sideload, or Play Services)."
+ }
+ return "Integrity: token length ${token.length} (send to server for verification)."
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/SecureTokenStore.kt b/app/src/main/java/com/smoa/security/SecureTokenStore.kt
new file mode 100644
index 0000000..cfa2b3f
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/SecureTokenStore.kt
@@ -0,0 +1,73 @@
+package com.smoa.security
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Encrypted storage for OIDC/OAuth access and refresh tokens (and optional id_token).
+ * Replace demo sign-in with real IdP flows, then persist tokens here.
+ */
+@Singleton
+class SecureTokenStore @Inject constructor(
+ @ApplicationContext context: Context
+) {
+ private val prefs by lazy {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ EncryptedSharedPreferences.create(
+ context,
+ PREFS,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ fun peekAccessToken(): String? = prefs.getString(KEY_ACCESS, null)
+ fun peekRefreshToken(): String? = prefs.getString(KEY_REFRESH, null)
+ fun peekIdToken(): String? = prefs.getString(KEY_ID, null)
+ fun peekTokenEndpoint(): String? = prefs.getString(KEY_TOKEN_ENDPOINT, null)
+ fun peekOidcClientId(): String? = prefs.getString(KEY_OIDC_CLIENT_ID, null)
+
+ fun persistTokens(
+ accessToken: String?,
+ refreshToken: String?,
+ idToken: String? = null,
+ expiresAtEpochSeconds: Long? = null
+ ) {
+ prefs.edit().apply {
+ if (accessToken != null) putString(KEY_ACCESS, accessToken) else remove(KEY_ACCESS)
+ if (refreshToken != null) putString(KEY_REFRESH, refreshToken) else remove(KEY_REFRESH)
+ if (idToken != null) putString(KEY_ID, idToken) else remove(KEY_ID)
+ if (expiresAtEpochSeconds != null) putLong(KEY_EXPIRES, expiresAtEpochSeconds) else remove(KEY_EXPIRES)
+ apply()
+ }
+ }
+
+ fun persistOidcEndpoints(tokenEndpoint: String?, clientId: String?) {
+ prefs.edit().apply {
+ if (tokenEndpoint != null) putString(KEY_TOKEN_ENDPOINT, tokenEndpoint) else remove(KEY_TOKEN_ENDPOINT)
+ if (clientId != null) putString(KEY_OIDC_CLIENT_ID, clientId) else remove(KEY_OIDC_CLIENT_ID)
+ apply()
+ }
+ }
+
+ fun clearAll() {
+ prefs.edit().clear().apply()
+ }
+
+ companion object {
+ private const val PREFS = "smoa_oidc_tokens"
+ private const val KEY_ACCESS = "access_token"
+ private const val KEY_REFRESH = "refresh_token"
+ private const val KEY_ID = "id_token"
+ private const val KEY_EXPIRES = "expires_at_epoch"
+ private const val KEY_TOKEN_ENDPOINT = "token_endpoint"
+ private const val KEY_OIDC_CLIENT_ID = "oidc_client_id"
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/SecurityStatusViewModel.kt b/app/src/main/java/com/smoa/security/SecurityStatusViewModel.kt
new file mode 100644
index 0000000..e2fd12a
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/SecurityStatusViewModel.kt
@@ -0,0 +1,66 @@
+package com.smoa.security
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.BuildConfig
+import com.smoa.config.RemoteEndpointStore
+import com.smoa.network.NetworkPinningConfig
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class SecurityStatusViewModel @Inject constructor(
+ private val playIntegrityVerifier: PlayIntegrityVerifier,
+ private val knoxEnterpriseProbe: KnoxEnterpriseProbe,
+ private val secureTokenStore: SecureTokenStore,
+ private val biometricSecretsVault: BiometricSecretsVault,
+ private val remoteEndpointStore: RemoteEndpointStore
+) : ViewModel() {
+
+ private val _integrityLine = MutableStateFlow(null)
+ val integrityLine: StateFlow = _integrityLine.asStateFlow()
+
+ val tlsPinningActive: Boolean
+ get() {
+ val multi = BuildConfig.SMOA_TLS_PIN_SPEC.isNotBlank() ||
+ remoteEndpointStore.getTlsPinSpec()?.isNotBlank() == true
+ val single = BuildConfig.SMOA_TLS_PINS.isNotBlank() &&
+ NetworkPinningConfig.resolveBackendHost(remoteEndpointStore) != null
+ return multi || single
+ }
+
+ val oidcConfigured: Boolean get() = OidcBootstrap.isConfigured()
+
+ val classificationBuildMarking: String get() = BuildConfig.SMOA_CLASSIFICATION_BUILD_MARKING
+
+ val sessionTimeoutMinutes: Int get() = BuildConfig.SMOA_SESSION_TIMEOUT_MINUTES
+
+ val knoxSummary: String get() = knoxEnterpriseProbe.summary()
+
+ val bearerTokenPresent: Boolean get() = secureTokenStore.peekAccessToken() != null
+
+ val biometricKeyReady: Boolean get() = biometricSecretsVault.hasKey()
+
+ val oidcDiscoveryUrl: String? get() = OidcBootstrap.discoveryUrl()
+
+ fun clearStoredTokens() {
+ secureTokenStore.clearAll()
+ }
+
+ fun prepareBiometricKey() {
+ viewModelScope.launch(Dispatchers.IO) {
+ runCatching { biometricSecretsVault.getOrCreateSecretKey() }
+ }
+ }
+
+ fun runIntegrityCheck() {
+ viewModelScope.launch {
+ _integrityLine.value = playIntegrityVerifier.summarizeForDisplay()
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/SessionLockController.kt b/app/src/main/java/com/smoa/security/SessionLockController.kt
new file mode 100644
index 0000000..504f7e3
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/SessionLockController.kt
@@ -0,0 +1,76 @@
+package com.smoa.security
+
+import android.os.SystemClock
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
+import com.smoa.BuildConfig
+import com.smoa.core.auth.UserSession
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Locks the UI after a period in background while a user session exists. Unlock with biometric (or device credential via policy).
+ * Set [BuildConfig.SMOA_SESSION_TIMEOUT_MINUTES] to `0` in Gradle to disable.
+ */
+@Singleton
+class SessionLockController @Inject constructor(
+ private val userSession: UserSession
+) : DefaultLifecycleObserver {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private val started = AtomicBoolean(false)
+ private var backgroundElapsedRealtime: Long = 0L
+
+ private val _locked = MutableStateFlow(false)
+ val locked: StateFlow = _locked.asStateFlow()
+
+ private val timeoutMs: Long
+ get() {
+ val m = BuildConfig.SMOA_SESSION_TIMEOUT_MINUTES
+ return if (m <= 0) 0L else m * 60_000L
+ }
+
+ fun start() {
+ if (!started.compareAndSet(false, true)) return
+ ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ scope.launch {
+ userSession.currentUser.collect { user ->
+ if (user == null) {
+ _locked.value = false
+ backgroundElapsedRealtime = 0L
+ }
+ }
+ }
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ if (timeoutMs <= 0L) return
+ if (userSession.currentUser.value != null) {
+ backgroundElapsedRealtime = SystemClock.elapsedRealtime()
+ }
+ }
+
+ override fun onStart(owner: LifecycleOwner) {
+ if (timeoutMs <= 0L) return
+ if (userSession.currentUser.value == null) return
+ if (backgroundElapsedRealtime == 0L) return
+ val elapsed = SystemClock.elapsedRealtime() - backgroundElapsedRealtime
+ if (SessionLockLogic.shouldLockAfterBackground(elapsed, timeoutMs, sessionActive = true)) {
+ _locked.value = true
+ }
+ backgroundElapsedRealtime = 0L
+ }
+
+ fun unlockAfterAuthentication() {
+ _locked.value = false
+ }
+}
diff --git a/app/src/main/java/com/smoa/security/SessionLockLogic.kt b/app/src/main/java/com/smoa/security/SessionLockLogic.kt
new file mode 100644
index 0000000..05a73d6
--- /dev/null
+++ b/app/src/main/java/com/smoa/security/SessionLockLogic.kt
@@ -0,0 +1,17 @@
+package com.smoa.security
+
+/**
+ * Pure helpers for background timeout policy (testable without Android lifecycle).
+ */
+object SessionLockLogic {
+
+ fun shouldLockAfterBackground(
+ elapsedBackgroundMs: Long,
+ timeoutMs: Long,
+ sessionActive: Boolean
+ ): Boolean {
+ if (!sessionActive) return false
+ if (timeoutMs <= 0L) return false
+ return elapsedBackgroundMs >= timeoutMs
+ }
+}
diff --git a/app/src/main/java/com/smoa/sync/CompositeSyncRemoteDataCache.kt b/app/src/main/java/com/smoa/sync/CompositeSyncRemoteDataCache.kt
new file mode 100644
index 0000000..bbf4506
--- /dev/null
+++ b/app/src/main/java/com/smoa/sync/CompositeSyncRemoteDataCache.kt
@@ -0,0 +1,34 @@
+package com.smoa.sync
+
+import com.smoa.core.common.sync.SyncRemoteDataCachePort
+import com.smoa.modules.credentials.data.CredentialCacheMerger
+
+/**
+ * Credentials use structured Room ([CredentialCacheMerger]); other sync types store raw conflict JSON
+ * in [SyncConflictSnapshotStore] for offline inspection until feature-specific caches exist.
+ */
+class CompositeSyncRemoteDataCache(
+ private val credentialMerger: CredentialCacheMerger,
+ private val snapshots: SyncConflictSnapshotStore
+) : SyncRemoteDataCachePort {
+
+ override suspend fun mergeCredentialConflictRemoteData(json: ByteArray, itemId: String) {
+ credentialMerger.mergeCredentialConflictRemoteData(json, itemId)
+ }
+
+ override suspend fun mergeOrderConflictRemoteData(json: ByteArray, itemId: String) {
+ snapshots.save(resourceType = "order", itemId = itemId, body = json)
+ }
+
+ override suspend fun mergeEvidenceConflictRemoteData(json: ByteArray, itemId: String) {
+ snapshots.save(resourceType = "evidence", itemId = itemId, body = json)
+ }
+
+ override suspend fun mergeDirectoryConflictRemoteData(json: ByteArray, itemId: String) {
+ snapshots.save(resourceType = "directory", itemId = itemId, body = json)
+ }
+
+ override suspend fun mergeReportConflictRemoteData(json: ByteArray, itemId: String) {
+ snapshots.save(resourceType = "report", itemId = itemId, body = json)
+ }
+}
diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDao.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDao.kt
new file mode 100644
index 0000000..3d9b192
--- /dev/null
+++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDao.kt
@@ -0,0 +1,33 @@
+package com.smoa.sync
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface SyncConflictSnapshotDao {
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(row: SyncConflictSnapshotEntity)
+
+ @Query(
+ "SELECT * FROM sync_conflict_snapshots WHERE resourceType = :resourceType AND itemId = :itemId LIMIT 1"
+ )
+ suspend fun get(resourceType: String, itemId: String): SyncConflictSnapshotEntity?
+
+ @Query("SELECT * FROM sync_conflict_snapshots ORDER BY savedAt DESC")
+ fun observeAll(): Flow>
+
+ @Query(
+ "SELECT * FROM sync_conflict_snapshots WHERE resourceType = :resourceType ORDER BY savedAt DESC"
+ )
+ fun observeByResourceType(resourceType: String): Flow>
+
+ @Query("DELETE FROM sync_conflict_snapshots WHERE resourceType = :resourceType AND itemId = :itemId")
+ suspend fun delete(resourceType: String, itemId: String)
+
+ @Query("DELETE FROM sync_conflict_snapshots")
+ suspend fun clearAll()
+}
diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDatabase.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDatabase.kt
new file mode 100644
index 0000000..bee926b
--- /dev/null
+++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotDatabase.kt
@@ -0,0 +1,13 @@
+package com.smoa.sync
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(
+ entities = [SyncConflictSnapshotEntity::class],
+ version = 1,
+ exportSchema = false
+)
+abstract class SyncConflictSnapshotDatabase : RoomDatabase() {
+ abstract fun dao(): SyncConflictSnapshotDao
+}
diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotEntity.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotEntity.kt
new file mode 100644
index 0000000..87446b6
--- /dev/null
+++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotEntity.kt
@@ -0,0 +1,14 @@
+package com.smoa.sync
+
+import androidx.room.Entity
+
+@Entity(
+ tableName = "sync_conflict_snapshots",
+ primaryKeys = ["resourceType", "itemId"]
+)
+data class SyncConflictSnapshotEntity(
+ val resourceType: String,
+ val itemId: String,
+ val jsonUtf8: String,
+ val savedAt: Long
+)
diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotRepository.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotRepository.kt
new file mode 100644
index 0000000..31346f7
--- /dev/null
+++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotRepository.kt
@@ -0,0 +1,39 @@
+package com.smoa.sync
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Read and manage persisted sync conflict snapshots (order, evidence, directory, report).
+ * Compose UI can collect [observeSnapshots] or [observeByResourceType] for settings / debug / recovery flows.
+ */
+@Singleton
+class SyncConflictSnapshotRepository @Inject constructor(
+ private val dao: SyncConflictSnapshotDao
+) {
+
+ fun observeSnapshots(): Flow> = dao.observeAll()
+
+ fun observeByResourceType(resourceType: String): Flow> =
+ dao.observeByResourceType(resourceType)
+
+ suspend fun getSnapshot(resourceType: String, itemId: String): SyncConflictSnapshotEntity? =
+ withContext(Dispatchers.IO) {
+ dao.get(resourceType, itemId)
+ }
+
+ suspend fun deleteSnapshot(resourceType: String, itemId: String) {
+ withContext(Dispatchers.IO) {
+ dao.delete(resourceType, itemId)
+ }
+ }
+
+ suspend fun clearAll() {
+ withContext(Dispatchers.IO) {
+ dao.clearAll()
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/sync/SyncConflictSnapshotStore.kt b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotStore.kt
new file mode 100644
index 0000000..0612bfa
--- /dev/null
+++ b/app/src/main/java/com/smoa/sync/SyncConflictSnapshotStore.kt
@@ -0,0 +1,25 @@
+package com.smoa.sync
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SyncConflictSnapshotStore @Inject constructor(
+ private val dao: SyncConflictSnapshotDao
+) {
+
+ suspend fun save(resourceType: String, itemId: String, body: ByteArray) {
+ withContext(Dispatchers.IO) {
+ dao.upsert(
+ SyncConflictSnapshotEntity(
+ resourceType = resourceType,
+ itemId = itemId,
+ jsonUtf8 = body.toString(Charsets.UTF_8),
+ savedAt = System.currentTimeMillis()
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt b/app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt
new file mode 100644
index 0000000..497e2cf
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt
@@ -0,0 +1,410 @@
+package com.smoa.ui.auth
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material3.Button
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.biometric.BiometricManager
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.fragment.app.FragmentActivity
+import com.smoa.R
+import com.smoa.auth.BiometricAuthenticator
+import com.smoa.core.auth.RBACFramework
+import com.smoa.core.auth.UserInfo
+import com.smoa.core.auth.UserSession
+import com.smoa.security.OidcBootstrap
+import com.smoa.ui.components.ClassificationWatermark
+import com.smoa.ui.theme.SmoaSpatial
+
+private sealed class SignInStep {
+ data object Landing : SignInStep()
+ data object Credentials : SignInStep()
+ data object Mfa : SignInStep()
+ data object Biometric : SignInStep()
+}
+
+/**
+ * Onboarding, then landing → credentials → MFA → biometric before session is established.
+ */
+@Composable
+fun AuthFlowHost(
+ activity: FragmentActivity,
+ userSession: UserSession,
+ windowSizeClass: WindowSizeClass,
+ oidcAvailable: Boolean = OidcBootstrap.isConfigured(),
+ onOrganizationSignIn: () -> Unit = {},
+ modifier: Modifier = Modifier
+) {
+ val padH = SmoaSpatial.horizontalPadding(windowSizeClass)
+ val padV = SmoaSpatial.verticalPadding(windowSizeClass)
+ var step by remember { mutableStateOf(SignInStep.Landing) }
+ var userId by remember { mutableStateOf("") }
+ var password by remember { mutableStateOf("") }
+ var mfaCode by remember { mutableStateOf("") }
+ var errorText by remember { mutableStateOf(null) }
+ var signInUsesDeviceCredential by remember { mutableStateOf(false) }
+ var biometricLaunchEpoch by remember { mutableStateOf(0) }
+
+ val biometric = remember(activity) { BiometricAuthenticator(activity) }
+
+ fun completeSignIn() {
+ val role = if (userId.equals("admin", ignoreCase = true)) {
+ RBACFramework.Role.ADMIN
+ } else {
+ RBACFramework.Role.OPERATOR
+ }
+ userSession.setUser(
+ UserInfo(
+ userId = userId.trim(),
+ userName = userId.trim(),
+ role = role,
+ unit = "Unit1",
+ clearanceLevel = null,
+ missionAssignment = null
+ )
+ )
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ ClassificationWatermark(Modifier.fillMaxSize())
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = padH, vertical = padV),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Image(
+ painter = painterResource(R.drawable.ic_smo_mark),
+ contentDescription = null,
+ modifier = Modifier.height(56.dp)
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AnimatedContent(
+ targetState = step,
+ transitionSpec = {
+ fadeIn(animationSpec = tween(280)) togetherWith fadeOut(animationSpec = tween(220))
+ },
+ label = "signInStep"
+ ) { s ->
+ when (s) {
+ SignInStep.Landing -> {
+ Text(
+ text = stringResource(R.string.auth_landing_title),
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.auth_landing_body),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = { step = SignInStep.Credentials },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.auth_sign_in))
+ }
+ if (oidcAvailable) {
+ Spacer(modifier = Modifier.height(12.dp))
+ OutlinedButton(
+ onClick = onOrganizationSignIn,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.auth_sign_in_organization))
+ }
+ }
+ }
+
+ SignInStep.Credentials -> {
+ Text(
+ text = stringResource(R.string.auth_sign_in),
+ style = MaterialTheme.typography.titleLarge
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ OutlinedTextField(
+ value = userId,
+ onValueChange = { userId = it; errorText = null },
+ label = { Text(stringResource(R.string.auth_user_id)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = password,
+ onValueChange = { password = it; errorText = null },
+ label = { Text(stringResource(R.string.auth_password)) },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+ modifier = Modifier.fillMaxWidth()
+ )
+ errorText?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = {
+ when {
+ userId.length < 3 -> errorText = activity.getString(R.string.auth_error_user_id)
+ password.length < 4 -> errorText = activity.getString(R.string.auth_error_password)
+ else -> step = SignInStep.Mfa
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.auth_continue))
+ }
+ if (oidcAvailable) {
+ Spacer(modifier = Modifier.height(10.dp))
+ OutlinedButton(
+ onClick = onOrganizationSignIn,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.auth_sign_in_organization))
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(onClick = { step = SignInStep.Landing }) {
+ Text(stringResource(R.string.auth_back))
+ }
+ }
+
+ SignInStep.Mfa -> {
+ Text(
+ text = stringResource(R.string.auth_mfa_title),
+ style = MaterialTheme.typography.titleLarge
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.auth_mfa_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ OutlinedTextField(
+ value = mfaCode,
+ onValueChange = { v -> mfaCode = v.filter { it.isDigit() }.take(6); errorText = null },
+ label = { Text(stringResource(R.string.auth_mfa_code)) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
+ leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
+ modifier = Modifier.fillMaxWidth()
+ )
+ errorText?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = {
+ if (mfaCode.length != 6) {
+ errorText = activity.getString(R.string.auth_mfa_invalid_length)
+ return@Button
+ }
+ // Replace with server TOTP/WebAuthn verification when backend is wired.
+ if (mfaCode != DEMO_MFA_CODE) {
+ errorText = activity.getString(R.string.auth_mfa_invalid)
+ return@Button
+ }
+ val bio = biometric.canAuthenticateWithBiometrics()
+ val dev = biometric.canAuthenticateWithDeviceCredential()
+ when {
+ bio == BiometricManager.BIOMETRIC_SUCCESS -> {
+ signInUsesDeviceCredential = false
+ biometricLaunchEpoch++
+ step = SignInStep.Biometric
+ }
+ dev == BiometricManager.BIOMETRIC_SUCCESS -> {
+ signInUsesDeviceCredential = true
+ biometricLaunchEpoch++
+ step = SignInStep.Biometric
+ }
+ else -> {
+ errorText = activity.getString(R.string.auth_biometric_unavailable)
+ }
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.auth_verify_mfa))
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(onClick = { step = SignInStep.Credentials }) {
+ Text(stringResource(R.string.auth_back))
+ }
+ }
+
+ SignInStep.Biometric -> {
+ SignInBiometricStep(
+ launchEpoch = biometricLaunchEpoch,
+ errorText = errorText,
+ biometric = biometric,
+ useDeviceCredentialFallback = signInUsesDeviceCredential,
+ onSuccess = { completeSignIn() },
+ onError = { msg -> errorText = msg },
+ onClearError = { errorText = null },
+ onCancelled = { step = SignInStep.Mfa }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SignInBiometricStep(
+ launchEpoch: Int,
+ errorText: String?,
+ biometric: BiometricAuthenticator,
+ useDeviceCredentialFallback: Boolean,
+ onSuccess: () -> Unit,
+ onError: (String) -> Unit,
+ onClearError: () -> Unit,
+ onCancelled: () -> Unit
+) {
+ LaunchedEffect(launchEpoch) {
+ onClearError()
+ biometric.authenticate(
+ onSuccess = onSuccess,
+ onError = onError,
+ onCancelled = onCancelled,
+ useDeviceCredentialFallback = useDeviceCredentialFallback
+ )
+ }
+ Text(
+ text = stringResource(R.string.auth_biometric_required),
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.auth_biometric_prompt_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ errorText?.let {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ OutlinedButton(
+ onClick = {
+ onClearError()
+ biometric.authenticate(
+ onSuccess = onSuccess,
+ onError = onError,
+ onCancelled = { },
+ useDeviceCredentialFallback = useDeviceCredentialFallback
+ )
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.auth_biometric_retry))
+ }
+}
+
+private const val DEMO_MFA_CODE = "123456"
+
+@Composable
+fun OnboardingPager(
+ onFinished: () -> Unit,
+ windowSizeClass: WindowSizeClass,
+ modifier: Modifier = Modifier
+) {
+ var page by remember { mutableStateOf(0) }
+ val pages = listOf(
+ stringResource(R.string.onboarding_page1_title) to stringResource(R.string.onboarding_page1_body),
+ stringResource(R.string.onboarding_page2_title) to stringResource(R.string.onboarding_page2_body),
+ stringResource(R.string.onboarding_page3_title) to stringResource(R.string.onboarding_page3_body)
+ )
+ val padH = SmoaSpatial.horizontalPadding(windowSizeClass)
+ val padV = SmoaSpatial.verticalPadding(windowSizeClass)
+ Box(modifier = modifier.fillMaxSize()) {
+ ClassificationWatermark(Modifier.fillMaxSize())
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = padH, vertical = padV),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ AnimatedContent(
+ targetState = page,
+ transitionSpec = {
+ fadeIn(tween(320)) togetherWith fadeOut(tween(240))
+ },
+ label = "onboardingPage"
+ ) { p ->
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = pages[p].first,
+ style = MaterialTheme.typography.headlineSmall,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = pages[p].second,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(32.dp))
+ if (page < pages.lastIndex) {
+ Button(onClick = { page++ }, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.onboarding_next))
+ }
+ } else {
+ Button(onClick = onFinished, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.onboarding_get_started))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt b/app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt
new file mode 100644
index 0000000..8e3cf76
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt
@@ -0,0 +1,67 @@
+package com.smoa.ui.auth
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.FragmentActivity
+import com.smoa.R
+import com.smoa.auth.BiometricAuthenticator
+
+@Composable
+fun SessionLockOverlay(
+ activity: FragmentActivity,
+ onUnlocked: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val biometric = remember(activity) { BiometricAuthenticator(activity) }
+ Box(
+ modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.62f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(28.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.session_lock_title),
+ style = MaterialTheme.typography.headlineSmall,
+ color = Color.White,
+ textAlign = TextAlign.Center
+ )
+ Text(
+ text = stringResource(R.string.session_lock_body),
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White.copy(alpha = 0.88f),
+ textAlign = TextAlign.Center
+ )
+ Button(
+ onClick = {
+ biometric.authenticateForSessionUnlock(
+ onSuccess = onUnlocked,
+ onError = { },
+ onCancelled = { }
+ )
+ }
+ ) {
+ Text(stringResource(R.string.session_lock_unlock))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/ui/components/ModulePlaceholderImage.kt b/app/src/main/java/com/smoa/ui/components/ModulePlaceholderImage.kt
new file mode 100644
index 0000000..b89daed
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/components/ModulePlaceholderImage.kt
@@ -0,0 +1,28 @@
+package com.smoa.ui.components
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.smoa.R
+
+/**
+ * Vector placeholder for module hero / list graphics until final assets are approved.
+ */
+@Composable
+fun ModulePlaceholderImage(
+ modifier: Modifier = Modifier,
+ size: Dp = 72.dp
+) {
+ Image(
+ painter = painterResource(R.drawable.ic_module_placeholder),
+ contentDescription = stringResource(R.string.content_desc_module_placeholder),
+ modifier = modifier.size(size),
+ contentScale = ContentScale.Fit
+ )
+}
diff --git a/app/src/main/java/com/smoa/ui/components/SmoaChrome.kt b/app/src/main/java/com/smoa/ui/components/SmoaChrome.kt
new file mode 100644
index 0000000..4c60fc6
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/components/SmoaChrome.kt
@@ -0,0 +1,119 @@
+package com.smoa.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import com.smoa.di.SmoaRemoteConfigEntryPoint
+import dagger.hilt.android.EntryPointAccessors
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.smoa.R
+
+/**
+ * Diagonal low-contrast classification marking (placeholder for org-specific CUI/FOUO labels).
+ * Does not intercept touches; drawn behind interactive content.
+ */
+@Composable
+fun ClassificationWatermark(
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ val store = remember(context) {
+ EntryPointAccessors.fromApplication(
+ context.applicationContext,
+ SmoaRemoteConfigEntryPoint::class.java
+ ).remoteEndpointStore()
+ }
+ val epoch by store.configEpoch.collectAsState(initial = 0L)
+ val defaultPrimary = stringResource(R.string.classification_watermark_primary)
+ val defaultSecondary = stringResource(R.string.classification_watermark_secondary)
+ val remotePrimary = remember(epoch) { store.getClassificationWatermarkPrimary() }
+ val remoteSecondary = remember(epoch) { store.getClassificationWatermarkSecondary() }
+ val primary = remotePrimary ?: defaultPrimary
+ val secondary = remoteSecondary ?: defaultSecondary
+ Box(
+ modifier = modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = primary,
+ style = MaterialTheme.typography.displaySmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.045f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.graphicsLayer {
+ rotationZ = -32f
+ scaleX = 1.15f
+ scaleY = 1.15f
+ }
+ )
+ Text(
+ text = secondary,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.04f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .padding(top = 120.dp)
+ .graphicsLayer { rotationZ = 24f }
+ )
+ }
+}
+
+/**
+ * Constrains main session content width on wide screens; centers column; draws watermark under content.
+ */
+@Composable
+fun SmoaSessionScaffoldFrame(
+ maxContentWidth: Dp,
+ modifier: Modifier = Modifier,
+ content: @Composable BoxScope.() -> Unit
+) {
+ BoxWithConstraints(modifier = modifier.fillMaxSize()) {
+ Box(Modifier.fillMaxSize()) {
+ ClassificationWatermark()
+ }
+ val inner = if (maxContentWidth != Dp.Unspecified) {
+ Modifier
+ .widthIn(max = maxContentWidth)
+ .align(Alignment.TopCenter)
+ .fillMaxWidth()
+ } else {
+ Modifier.fillMaxSize()
+ }
+ Box(inner) {
+ content()
+ }
+ }
+}
+
+@Composable
+fun SecurityPolicyStrip(modifier: Modifier = Modifier) {
+ Surface(
+ tonalElevation = 1.dp,
+ color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.55f),
+ modifier = modifier.fillMaxWidth()
+ ) {
+ Text(
+ text = stringResource(R.string.security_policy_strip),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
+ )
+ }
+}
diff --git a/app/src/main/java/com/smoa/ui/main/HomeScreen.kt b/app/src/main/java/com/smoa/ui/main/HomeScreen.kt
new file mode 100644
index 0000000..42fd407
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/main/HomeScreen.kt
@@ -0,0 +1,185 @@
+package com.smoa.ui.main
+
+import android.content.Intent
+import android.provider.Settings
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.GridItemSpan
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.navigation.NavController
+import com.smoa.R
+import com.smoa.core.auth.RBACFramework
+import com.smoa.ui.components.ModulePlaceholderImage
+import com.smoa.ui.navigation.SMOARoute
+import com.smoa.ui.navigation.navigateSmoa
+import com.smoa.ui.theme.SmoaSpatial
+
+private data class HomeRoute(
+ val labelRes: Int,
+ val route: String,
+ val module: RBACFramework.Module?,
+ val icon: ImageVector
+)
+
+/**
+ * Adaptive hub: responsive grid on tablets/foldables, cards with placeholder art, launcher settings.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ navController: NavController,
+ userRole: RBACFramework.Role,
+ rbacFramework: RBACFramework,
+ windowSizeClass: WindowSizeClass,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ val hPad = SmoaSpatial.horizontalPadding(windowSizeClass)
+ val vPad = SmoaSpatial.verticalPadding(windowSizeClass)
+
+ val allRoutes = remember {
+ listOf(
+ HomeRoute(R.string.module_credentials, SMOARoute.Credentials.route, null, Icons.Default.Info),
+ HomeRoute(R.string.home_orders, SMOARoute.Orders.route, null, Icons.Default.Description),
+ HomeRoute(R.string.home_evidence, SMOARoute.Evidence.route, null, Icons.Default.Folder),
+ HomeRoute(R.string.home_reports, SMOARoute.Reports.route, null, Icons.Default.Description),
+ HomeRoute(R.string.module_directory, SMOARoute.Directory.route, RBACFramework.Module.DIRECTORY, Icons.Default.Person),
+ HomeRoute(R.string.module_communications, SMOARoute.Communications.route, RBACFramework.Module.COMMUNICATIONS, Icons.Default.Phone),
+ HomeRoute(R.string.module_meetings, SMOARoute.Meetings.route, RBACFramework.Module.MEETINGS, Icons.Default.Phone),
+ HomeRoute(R.string.module_browser, SMOARoute.Browser.route, RBACFramework.Module.BROWSER, Icons.Default.Info)
+ )
+ }
+ val tiles = remember(userRole, rbacFramework) {
+ allRoutes.filter { route ->
+ route.module == null || rbacFramework.canAccessModule(userRole, route.module)
+ }
+ }
+
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 280.dp),
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = hPad, vertical = vPad),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ Column(Modifier.fillMaxWidth()) {
+ Text(
+ text = stringResource(R.string.home_title),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(Modifier.height(6.dp))
+ Text(
+ text = stringResource(R.string.home_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.regulatory_footer_notice),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ items(tiles, key = { it.route }) { tile ->
+ Card(
+ onClick = { navController.navigateSmoa(tile.route) },
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
+ ) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ModulePlaceholderImage(size = 52.dp)
+ Column(Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Icon(tile.icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ Text(
+ text = stringResource(tile.labelRes),
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ Text(
+ text = stringResource(R.string.home_module_tap_open),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ Card(
+ onClick = { navController.navigateSmoa(SMOARoute.UserSettings.route) },
+ modifier = Modifier.fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
+ ) {
+ Row(
+ Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Settings, contentDescription = null)
+ Text(stringResource(R.string.user_settings_title), style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ }
+ item(span = { GridItemSpan(maxLineSpan) }) {
+ OutlinedButton(
+ onClick = {
+ context.startActivity(Intent(Settings.ACTION_HOME_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Home, contentDescription = null)
+ Text(stringResource(R.string.home_set_default_launcher))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/ui/main/MainScreen.kt b/app/src/main/java/com/smoa/ui/main/MainScreen.kt
index b1b9e67..9789d7c 100644
--- a/app/src/main/java/com/smoa/ui/main/MainScreen.kt
+++ b/app/src/main/java/com/smoa/ui/main/MainScreen.kt
@@ -1,8 +1,16 @@
package com.smoa.ui.main
+import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -13,6 +21,8 @@ import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -20,30 +30,37 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Menu
import androidx.navigation.compose.rememberNavController
+import com.smoa.R
import com.smoa.core.auth.RBACFramework
import com.smoa.core.auth.UserSession
import com.smoa.core.common.ConnectivityManager
import com.smoa.core.common.FoldableStateManager
+import com.smoa.core.security.ScreenProtection
+import com.smoa.core.security.VPNManager
import com.smoa.modules.browser.domain.BrowserService
import com.smoa.modules.browser.domain.URLFilter
import com.smoa.modules.communications.domain.CommunicationsService
import com.smoa.modules.directory.domain.DirectoryService
import com.smoa.modules.meetings.domain.MeetingsService
-import com.smoa.core.security.ScreenProtection
-import com.smoa.core.security.VPNManager
+import com.smoa.ui.components.SecurityPolicyStrip
+import com.smoa.ui.components.SmoaSessionScaffoldFrame
import com.smoa.ui.navigation.SMOANavigation
+import com.smoa.ui.theme.SmoaSpatial
/**
- * Main application screen with module navigation.
+ * Main application screen with module navigation, responsive frame, and policy strip.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
+ windowSizeClass: WindowSizeClass,
connectivityManager: ConnectivityManager,
foldableStateManager: FoldableStateManager,
userSession: UserSession,
@@ -60,14 +77,14 @@ fun MainScreen(
val navController = rememberNavController()
val currentUser by userSession.currentUser.collectAsState()
var drawerOpen by remember { mutableStateOf(false) }
-
+
val userRole = currentUser?.role ?: RBACFramework.Role.GUEST
val userUnit = currentUser?.unit
val userId = currentUser?.userId ?: "guest"
val drawerState = remember { DrawerState(DrawerValue.Closed) }
-
- // Update drawer state when drawerOpen changes
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+
LaunchedEffect(drawerOpen) {
if (drawerOpen) {
drawerState.open()
@@ -75,57 +92,91 @@ fun MainScreen(
drawerState.close()
}
}
-
- // Update drawerOpen when drawer state changes
+
LaunchedEffect(drawerState.currentValue) {
drawerOpen = drawerState.currentValue == DrawerValue.Open
}
-
- ModalNavigationDrawer(
- drawerState = drawerState,
- drawerContent = {
- com.smoa.ui.navigation.NavigationDrawer(
- navController = navController,
- userSession = userSession,
- rbacFramework = rbacFramework,
- onDrawerDismiss = { drawerOpen = false }
- )
- },
- modifier = modifier
+
+ SmoaSessionScaffoldFrame(
+ maxContentWidth = SmoaSpatial.contentMaxWidth(windowSizeClass),
+ modifier = modifier.fillMaxSize()
) {
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text("SMOA") },
- navigationIcon = {
- IconButton(onClick = { drawerOpen = true }) {
- Icon(Icons.Default.Menu, contentDescription = "Menu")
- }
- },
- actions = {
- // VPN status indicator
- VPNStatusIndicator(vpnManager)
- // Connectivity status indicator
- ConnectivityStatusIndicator(connectivityManager)
- }
+ ModalNavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = {
+ com.smoa.ui.navigation.NavigationDrawer(
+ navController = navController,
+ userSession = userSession,
+ rbacFramework = rbacFramework,
+ onDrawerDismiss = { drawerOpen = false }
)
},
- modifier = Modifier
- ) { paddingValues ->
- SMOANavigation(
- navController = navController,
- connectivityManager = connectivityManager,
- foldableStateManager = foldableStateManager,
- directoryService = directoryService,
- communicationsService = communicationsService,
- meetingsService = meetingsService,
- browserService = browserService,
- urlFilter = urlFilter,
- screenProtection = screenProtection,
- userRole = userRole,
- userUnit = userUnit,
- userId = userId
- )
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Scaffold(
+ modifier = Modifier
+ .fillMaxSize()
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ Column(Modifier.fillMaxWidth()) {
+ TopAppBar(
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Image(
+ painter = painterResource(R.drawable.ic_smo_mark),
+ contentDescription = null,
+ modifier = Modifier.size(32.dp)
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(
+ stringResource(R.string.app_name),
+ style = MaterialTheme.typography.titleLarge
+ )
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = { drawerOpen = true }) {
+ Icon(
+ Icons.Default.Menu,
+ contentDescription = stringResource(R.string.content_desc_menu)
+ )
+ }
+ },
+ actions = {
+ VPNStatusIndicator(vpnManager)
+ ConnectivityStatusIndicator(connectivityManager)
+ },
+ scrollBehavior = scrollBehavior,
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface
+ )
+ )
+ SecurityPolicyStrip()
+ }
+ },
+ ) { paddingValues ->
+ SMOANavigation(
+ navController = navController,
+ windowSizeClass = windowSizeClass,
+ connectivityManager = connectivityManager,
+ foldableStateManager = foldableStateManager,
+ directoryService = directoryService,
+ communicationsService = communicationsService,
+ meetingsService = meetingsService,
+ browserService = browserService,
+ urlFilter = urlFilter,
+ screenProtection = screenProtection,
+ rbacFramework = rbacFramework,
+ userSession = userSession,
+ userRole = userRole,
+ userUnit = userUnit,
+ userId = userId,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ )
+ }
}
}
}
@@ -144,7 +195,7 @@ fun VPNStatusIndicator(
com.smoa.core.security.VPNState.Error -> "VPN ERR"
com.smoa.core.security.VPNState.Unknown -> "VPN ?"
}
-
+
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
@@ -170,11 +221,10 @@ fun ConnectivityStatusIndicator(
ConnectivityManager.ConnectivityState.Restricted -> "RESTRICTED"
ConnectivityManager.ConnectivityState.Unknown -> "UNKNOWN"
}
-
+
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
modifier = modifier.padding(horizontal = 8.dp)
)
}
-
diff --git a/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt b/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt
index 4365481..843e858 100644
--- a/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt
+++ b/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt
@@ -2,18 +2,22 @@ package com.smoa.ui.navigation
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
+import com.smoa.R
import com.smoa.core.auth.RBACFramework
import com.smoa.core.auth.UserSession
-import javax.inject.Inject
/**
* Navigation drawer for module selection.
@@ -53,12 +57,51 @@ fun NavigationDrawer(
Divider(modifier = Modifier.padding(vertical = 8.dp))
- // Navigation items
- NavigationDrawerItem(
- label = { Text("Credentials") },
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.home_title)) },
+ selected = navController.currentDestination?.route == SMOARoute.Home.route,
+ onClick = {
+ navController.navigateSmoa(SMOARoute.Home.route)
+ onDrawerDismiss()
+ },
+ icon = { Icon(Icons.Default.Home, contentDescription = null) }
+ )
+
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.module_credentials)) },
selected = navController.currentDestination?.route == SMOARoute.Credentials.route,
onClick = {
- navController.navigate(SMOARoute.Credentials.route)
+ navController.navigateSmoa(SMOARoute.Credentials.route)
+ onDrawerDismiss()
+ },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.home_orders)) },
+ selected = navController.currentDestination?.route == SMOARoute.Orders.route,
+ onClick = {
+ navController.navigateSmoa(SMOARoute.Orders.route)
+ onDrawerDismiss()
+ },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.home_evidence)) },
+ selected = navController.currentDestination?.route == SMOARoute.Evidence.route,
+ onClick = {
+ navController.navigateSmoa(SMOARoute.Evidence.route)
+ onDrawerDismiss()
+ },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.home_reports)) },
+ selected = navController.currentDestination?.route == SMOARoute.Reports.route,
+ onClick = {
+ navController.navigateSmoa(SMOARoute.Reports.route)
onDrawerDismiss()
},
icon = { Icon(Icons.Default.Info, contentDescription = null) }
@@ -66,10 +109,10 @@ fun NavigationDrawer(
if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.DIRECTORY)) {
NavigationDrawerItem(
- label = { Text("Directory") },
+ label = { Text(stringResource(R.string.module_directory)) },
selected = navController.currentDestination?.route == SMOARoute.Directory.route,
onClick = {
- navController.navigate(SMOARoute.Directory.route)
+ navController.navigateSmoa(SMOARoute.Directory.route)
onDrawerDismiss()
},
icon = { Icon(Icons.Default.Person, contentDescription = null) }
@@ -78,10 +121,10 @@ fun NavigationDrawer(
if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.COMMUNICATIONS)) {
NavigationDrawerItem(
- label = { Text("Communications") },
+ label = { Text(stringResource(R.string.module_communications)) },
selected = navController.currentDestination?.route == SMOARoute.Communications.route,
onClick = {
- navController.navigate(SMOARoute.Communications.route)
+ navController.navigateSmoa(SMOARoute.Communications.route)
onDrawerDismiss()
},
icon = { Icon(Icons.Default.Phone, contentDescription = null) }
@@ -90,10 +133,10 @@ fun NavigationDrawer(
if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.MEETINGS)) {
NavigationDrawerItem(
- label = { Text("Meetings") },
+ label = { Text(stringResource(R.string.module_meetings)) },
selected = navController.currentDestination?.route == SMOARoute.Meetings.route,
onClick = {
- navController.navigate(SMOARoute.Meetings.route)
+ navController.navigateSmoa(SMOARoute.Meetings.route)
onDrawerDismiss()
},
icon = { Icon(Icons.Default.Phone, contentDescription = null) }
@@ -102,15 +145,28 @@ fun NavigationDrawer(
if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.BROWSER)) {
NavigationDrawerItem(
- label = { Text("Browser") },
+ label = { Text(stringResource(R.string.module_browser)) },
selected = navController.currentDestination?.route == SMOARoute.Browser.route,
onClick = {
- navController.navigate(SMOARoute.Browser.route)
+ navController.navigateSmoa(SMOARoute.Browser.route)
onDrawerDismiss()
},
icon = { Icon(Icons.Default.Info, contentDescription = null) }
)
}
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Divider(modifier = Modifier.padding(vertical = 8.dp))
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.user_settings_title)) },
+ selected = navController.currentDestination?.route == SMOARoute.UserSettings.route,
+ onClick = {
+ navController.navigateSmoa(SMOARoute.UserSettings.route)
+ onDrawerDismiss()
+ },
+ icon = { Icon(Icons.Default.Settings, contentDescription = null) }
+ )
}
}
}
diff --git a/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt b/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt
index 20387cb..a2fef57 100644
--- a/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt
+++ b/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt
@@ -1,10 +1,16 @@
package com.smoa.ui.navigation
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.smoa.core.auth.RBACFramework
+import com.smoa.core.auth.UserSession
+import com.smoa.ui.main.HomeScreen
+import com.smoa.ui.settings.UserSettingsScreen
import com.smoa.modules.browser.BrowserModule
import com.smoa.modules.browser.domain.BrowserService
import com.smoa.modules.browser.domain.URLFilter
@@ -15,6 +21,9 @@ import com.smoa.modules.directory.DirectoryModule
import com.smoa.modules.directory.domain.DirectoryService
import com.smoa.modules.meetings.MeetingsModule
import com.smoa.modules.meetings.domain.MeetingsService
+import com.smoa.modules.orders.ui.OrdersModule
+import com.smoa.modules.evidence.ui.EvidenceModule
+import com.smoa.modules.reports.ui.ReportGenerationScreen
import com.smoa.core.common.ConnectivityManager
import com.smoa.core.common.FoldableStateManager
import com.smoa.core.security.ScreenProtection
@@ -23,11 +32,16 @@ import com.smoa.core.security.ScreenProtection
* Navigation routes for SMOA modules.
*/
sealed class SMOARoute(val route: String) {
+ object Home : SMOARoute("home")
object Credentials : SMOARoute("credentials")
object Directory : SMOARoute("directory")
object Communications : SMOARoute("communications")
object Meetings : SMOARoute("meetings")
object Browser : SMOARoute("browser")
+ object Orders : SMOARoute("orders")
+ object Evidence : SMOARoute("evidence")
+ object Reports : SMOARoute("reports")
+ object UserSettings : SMOARoute("user_settings")
}
/**
@@ -37,6 +51,7 @@ sealed class SMOARoute(val route: String) {
@Composable
fun SMOANavigation(
navController: NavHostController,
+ windowSizeClass: WindowSizeClass,
connectivityManager: ConnectivityManager,
foldableStateManager: FoldableStateManager,
directoryService: DirectoryService,
@@ -45,26 +60,62 @@ fun SMOANavigation(
browserService: BrowserService,
urlFilter: URLFilter,
screenProtection: ScreenProtection,
+ rbacFramework: RBACFramework,
+ userSession: UserSession,
userRole: RBACFramework.Role,
userUnit: String?,
- userId: String
+ userId: String,
+ modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
- startDestination = SMOARoute.Credentials.route
+ startDestination = SMOARoute.Home.route,
+ modifier = modifier.fillMaxSize()
) {
+ composable(SMOARoute.Home.route) {
+ HomeScreen(
+ navController = navController,
+ userRole = userRole,
+ rbacFramework = rbacFramework,
+ windowSizeClass = windowSizeClass,
+ modifier = Modifier
+ )
+ }
+
+ composable(SMOARoute.UserSettings.route) {
+ UserSettingsScreen(
+ userSession = userSession,
+ modifier = Modifier
+ )
+ }
+
composable(SMOARoute.Credentials.route) {
CredentialsModule(
- modifier = androidx.compose.ui.Modifier
+ modifier = Modifier,
+ onOpenOrders = { navController.navigateSmoa(SMOARoute.Orders.route) },
+ onOpenEvidence = { navController.navigateSmoa(SMOARoute.Evidence.route) },
+ onOpenReports = { navController.navigateSmoa(SMOARoute.Reports.route) }
)
}
+ composable(SMOARoute.Orders.route) {
+ OrdersModule(modifier = Modifier)
+ }
+
+ composable(SMOARoute.Evidence.route) {
+ EvidenceModule(modifier = Modifier)
+ }
+
+ composable(SMOARoute.Reports.route) {
+ ReportGenerationScreen(modifier = Modifier)
+ }
+
composable(SMOARoute.Directory.route) {
DirectoryModule(
directoryService = directoryService,
userRole = userRole,
userUnit = userUnit,
- modifier = androidx.compose.ui.Modifier
+ modifier = Modifier
)
}
@@ -73,7 +124,7 @@ fun SMOANavigation(
communicationsService = communicationsService,
userRole = userRole,
userUnit = userUnit,
- modifier = androidx.compose.ui.Modifier
+ modifier = Modifier
)
}
@@ -82,7 +133,7 @@ fun SMOANavigation(
meetingsService = meetingsService,
userRole = userRole,
userId = userId,
- modifier = androidx.compose.ui.Modifier
+ modifier = Modifier
)
}
@@ -91,7 +142,7 @@ fun SMOANavigation(
browserService = browserService,
urlFilter = urlFilter,
screenProtection = screenProtection,
- modifier = androidx.compose.ui.Modifier
+ modifier = Modifier
)
}
}
diff --git a/app/src/main/java/com/smoa/ui/navigation/SmoaNav.kt b/app/src/main/java/com/smoa/ui/navigation/SmoaNav.kt
new file mode 100644
index 0000000..153cae4
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/navigation/SmoaNav.kt
@@ -0,0 +1,22 @@
+package com.smoa.ui.navigation
+
+import androidx.navigation.NavController
+import androidx.navigation.navOptions
+
+/**
+ * Drawer-friendly navigation: single top, state save/restore, pop back to [SMOARoute.Home] so the stack
+ * does not accumulate duplicate destinations (WCAG-friendly predictable back behavior).
+ */
+fun NavController.navigateSmoa(route: String) {
+ navigate(
+ route,
+ navOptions {
+ popUpTo(SMOARoute.Home.route) {
+ saveState = true
+ inclusive = false
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ )
+}
diff --git a/app/src/main/java/com/smoa/ui/root/AppRoot.kt b/app/src/main/java/com/smoa/ui/root/AppRoot.kt
new file mode 100644
index 0000000..b0890e4
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/root/AppRoot.kt
@@ -0,0 +1,136 @@
+package com.smoa.ui.root
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.Modifier
+import androidx.fragment.app.FragmentActivity
+import com.smoa.security.SessionLockController
+import com.smoa.ui.auth.SessionLockOverlay
+import com.smoa.auth.AuthPreferencesStore
+import com.smoa.core.auth.RBACFramework
+import com.smoa.core.auth.UserSession
+import com.smoa.core.common.ConnectivityManager
+import com.smoa.core.common.FoldableStateManager
+import com.smoa.core.security.ScreenProtection
+import com.smoa.core.security.VPNManager
+import com.smoa.modules.browser.domain.BrowserService
+import com.smoa.modules.browser.domain.URLFilter
+import com.smoa.modules.communications.domain.CommunicationsService
+import com.smoa.modules.directory.domain.DirectoryService
+import com.smoa.modules.meetings.domain.MeetingsService
+import com.smoa.ui.auth.AuthFlowHost
+import com.smoa.ui.auth.OnboardingPager
+import com.smoa.ui.main.MainScreen
+
+private sealed interface RootPhase {
+ data object Onboarding : RootPhase
+ data object Auth : RootPhase
+ data object Main : RootPhase
+}
+
+/**
+ * Chooses onboarding → sign-in (MFA + biometric) → main shell based on session and prefs.
+ */
+@Composable
+fun AppRoot(
+ activity: FragmentActivity,
+ windowSizeClass: WindowSizeClass,
+ sessionLockController: SessionLockController,
+ authPreferencesStore: AuthPreferencesStore,
+ userSession: UserSession,
+ connectivityManager: ConnectivityManager,
+ foldableStateManager: FoldableStateManager,
+ rbacFramework: RBACFramework,
+ directoryService: DirectoryService,
+ communicationsService: CommunicationsService,
+ meetingsService: MeetingsService,
+ browserService: BrowserService,
+ urlFilter: URLFilter,
+ screenProtection: ScreenProtection,
+ vpnManager: VPNManager,
+ oidcAvailable: Boolean = com.smoa.security.OidcBootstrap.isConfigured(),
+ onOrganizationSignIn: () -> Unit = {},
+ modifier: Modifier = Modifier
+) {
+ var onboardingComplete by remember {
+ mutableStateOf(authPreferencesStore.isOnboardingComplete())
+ }
+ val currentUser by userSession.currentUser.collectAsState()
+ val sessionLocked by sessionLockController.locked.collectAsState()
+
+ val phase: RootPhase = when {
+ !onboardingComplete -> RootPhase.Onboarding
+ currentUser == null -> RootPhase.Auth
+ else -> RootPhase.Main
+ }
+
+ AnimatedContent(
+ targetState = phase,
+ modifier = modifier,
+ transitionSpec = {
+ fadeIn(animationSpec = tween(340)) togetherWith fadeOut(animationSpec = tween(280))
+ },
+ label = "rootPhase"
+ ) { p ->
+ when (p) {
+ RootPhase.Onboarding -> {
+ OnboardingPager(
+ onFinished = {
+ authPreferencesStore.setOnboardingComplete()
+ onboardingComplete = true
+ },
+ windowSizeClass = windowSizeClass,
+ modifier = Modifier
+ )
+ }
+ RootPhase.Auth -> {
+ AuthFlowHost(
+ activity = activity,
+ userSession = userSession,
+ windowSizeClass = windowSizeClass,
+ oidcAvailable = oidcAvailable,
+ onOrganizationSignIn = onOrganizationSignIn,
+ modifier = Modifier
+ )
+ }
+ RootPhase.Main -> {
+ Box(Modifier.fillMaxSize()) {
+ MainScreen(
+ windowSizeClass = windowSizeClass,
+ connectivityManager = connectivityManager,
+ foldableStateManager = foldableStateManager,
+ userSession = userSession,
+ rbacFramework = rbacFramework,
+ directoryService = directoryService,
+ communicationsService = communicationsService,
+ meetingsService = meetingsService,
+ browserService = browserService,
+ urlFilter = urlFilter,
+ screenProtection = screenProtection,
+ vpnManager = vpnManager,
+ modifier = Modifier.fillMaxSize()
+ )
+ if (sessionLocked) {
+ SessionLockOverlay(
+ activity = activity,
+ onUnlocked = { sessionLockController.unlockAfterAuthentication() },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/smoa/ui/settings/UserSettingsScreen.kt b/app/src/main/java/com/smoa/ui/settings/UserSettingsScreen.kt
new file mode 100644
index 0000000..65ca239
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/settings/UserSettingsScreen.kt
@@ -0,0 +1,267 @@
+package com.smoa.ui.settings
+
+import android.content.ClipboardManager
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExitToApp
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Divider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.smoa.R
+import com.smoa.core.auth.UserSession
+import com.smoa.security.SecurityStatusViewModel
+
+@Composable
+fun UserSettingsScreen(
+ userSession: UserSession,
+ modifier: Modifier = Modifier,
+ securityVm: SecurityStatusViewModel = hiltViewModel()
+) {
+ val context = LocalContext.current
+ val currentUser by userSession.currentUser.collectAsState()
+ val integrityLine by securityVm.integrityLine.collectAsState()
+ var showLogoutConfirm by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.user_settings_title),
+ style = MaterialTheme.typography.headlineSmall
+ )
+ Text(
+ text = stringResource(R.string.user_settings_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f))
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(R.string.user_settings_account_section),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Divider()
+ SettingsRow(
+ label = stringResource(R.string.user_settings_user_id),
+ value = currentUser?.userId ?: "—"
+ )
+ SettingsRow(
+ label = stringResource(R.string.user_settings_display_name),
+ value = currentUser?.userName ?: "—"
+ )
+ SettingsRow(
+ label = stringResource(R.string.user_settings_role),
+ value = currentUser?.role?.name ?: "—"
+ )
+ currentUser?.unit?.let { unit ->
+ SettingsRow(
+ label = stringResource(R.string.user_settings_unit),
+ value = unit
+ )
+ }
+ }
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.45f))
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text(
+ text = stringResource(R.string.enterprise_security_title),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Divider()
+ SettingsRow(
+ label = stringResource(R.string.enterprise_build_classification),
+ value = securityVm.classificationBuildMarking
+ )
+ SettingsRow(
+ label = stringResource(R.string.enterprise_session_timeout),
+ value = if (securityVm.sessionTimeoutMinutes <= 0) {
+ stringResource(R.string.enterprise_session_disabled)
+ } else {
+ "${securityVm.sessionTimeoutMinutes} min"
+ }
+ )
+ SettingsRow(
+ label = stringResource(R.string.enterprise_tls_pinning),
+ value = if (securityVm.tlsPinningActive) stringResource(R.string.enterprise_yes) else stringResource(R.string.enterprise_no)
+ )
+ SettingsRow(
+ label = stringResource(R.string.enterprise_oidc),
+ value = if (securityVm.oidcConfigured) stringResource(R.string.enterprise_configured) else stringResource(R.string.enterprise_not_configured)
+ )
+ securityVm.oidcDiscoveryUrl?.let { url ->
+ Text(
+ text = stringResource(R.string.enterprise_oidc_discovery, url),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.85f)
+ )
+ }
+ SettingsRow(
+ label = stringResource(R.string.enterprise_bearer_token),
+ value = if (securityVm.bearerTokenPresent) stringResource(R.string.enterprise_present) else stringResource(R.string.enterprise_absent)
+ )
+ SettingsRow(
+ label = stringResource(R.string.enterprise_biometric_keystore),
+ value = if (securityVm.biometricKeyReady) stringResource(R.string.enterprise_ready) else stringResource(R.string.enterprise_not_initialized)
+ )
+ Text(
+ text = securityVm.knoxSummary,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.9f)
+ )
+ integrityLine?.let { line ->
+ Text(
+ text = line,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(onClick = { securityVm.runIntegrityCheck() }) {
+ Text(stringResource(R.string.enterprise_run_integrity))
+ }
+ OutlinedButton(onClick = { securityVm.prepareBiometricKey() }) {
+ Text(stringResource(R.string.enterprise_init_biometric_key))
+ }
+ }
+ }
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f))
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(R.string.user_settings_security_section),
+ style = MaterialTheme.typography.titleMedium
+ )
+ Divider()
+ Text(
+ text = stringResource(R.string.user_settings_security_body),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.9f)
+ )
+ Text(
+ text = stringResource(R.string.user_settings_integrations_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.75f)
+ )
+ }
+ }
+
+ OutlinedButton(
+ onClick = {
+ val uri = Uri.fromParts("package", context.packageName, null)
+ context.startActivity(
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = uri
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ )
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.user_settings_app_details))
+ }
+
+ Button(
+ onClick = { showLogoutConfirm = true },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError
+ )
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.ExitToApp, contentDescription = null)
+ Text(stringResource(R.string.auth_logout))
+ }
+ }
+ }
+
+ if (showLogoutConfirm) {
+ AlertDialog(
+ onDismissRequest = { showLogoutConfirm = false },
+ title = { Text(stringResource(R.string.auth_logout_confirm_title)) },
+ text = { Text(stringResource(R.string.auth_logout_confirm_body)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showLogoutConfirm = false
+ clearPrimaryClipboard(context)
+ securityVm.clearStoredTokens()
+ userSession.clearUser()
+ }
+ ) {
+ Text(stringResource(R.string.auth_logout))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showLogoutConfirm = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+ }
+}
+
+private fun clearPrimaryClipboard(context: android.content.Context) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ ContextCompat.getSystemService(context, ClipboardManager::class.java)?.clearPrimaryClip()
+ }
+}
+
+@Composable
+private fun SettingsRow(label: String, value: String) {
+ Column(Modifier.fillMaxWidth()) {
+ Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(value, style = MaterialTheme.typography.bodyLarge)
+ }
+}
diff --git a/app/src/main/java/com/smoa/ui/theme/SMOATheme.kt b/app/src/main/java/com/smoa/ui/theme/SMOATheme.kt
new file mode 100644
index 0000000..4894e3c
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/theme/SMOATheme.kt
@@ -0,0 +1,80 @@
+package com.smoa.ui.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.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+private val SmoaNavy = Color(0xFF1B4F72)
+private val SmoaNavyDark = Color(0xFF123A52)
+private val SmoaSlate = Color(0xFF3D4F5F)
+private val SmoaSurface = Color(0xFFF5F7FA)
+private val SmoaSurfaceVariant = Color(0xFFE8EEF4)
+private val SmoaError = Color(0xFFB3261E)
+
+private val SmoaLightColors = lightColorScheme(
+ primary = SmoaNavy,
+ onPrimary = Color.White,
+ primaryContainer = Color(0xFFD4E4F7),
+ onPrimaryContainer = SmoaNavyDark,
+ secondary = SmoaSlate,
+ onSecondary = Color.White,
+ secondaryContainer = Color(0xFFDCE3EA),
+ onSecondaryContainer = Color(0xFF1C2730),
+ tertiary = Color(0xFF5D6B7A),
+ background = SmoaSurface,
+ onBackground = Color(0xFF1A1C1E),
+ surface = Color.White,
+ onSurface = Color(0xFF1A1C1E),
+ surfaceVariant = SmoaSurfaceVariant,
+ onSurfaceVariant = Color(0xFF42474E),
+ outline = Color(0xFF72787E),
+ error = SmoaError,
+ onError = Color.White
+)
+
+private val baselineTypography = Typography()
+
+private val SmoaTypography = baselineTypography.copy(
+ headlineLarge = baselineTypography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
+ headlineMedium = baselineTypography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
+ headlineSmall = baselineTypography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
+ titleLarge = baselineTypography.titleLarge.copy(fontWeight = FontWeight.Medium),
+ titleMedium = baselineTypography.titleMedium.copy(fontWeight = FontWeight.Medium),
+ bodyLarge = baselineTypography.bodyLarge.copy(
+ lineHeight = 24.sp,
+ letterSpacing = 0.4.sp
+ ),
+ bodyMedium = baselineTypography.bodyMedium.copy(letterSpacing = 0.25.sp),
+ labelSmall = baselineTypography.labelSmall.copy(
+ fontWeight = FontWeight.Medium,
+ letterSpacing = 0.6.sp
+ )
+)
+
+private val SmoaShapes = Shapes(
+ extraSmall = RoundedCornerShape(6.dp),
+ small = RoundedCornerShape(10.dp),
+ medium = RoundedCornerShape(14.dp),
+ large = RoundedCornerShape(18.dp),
+ extraLarge = RoundedCornerShape(24.dp)
+)
+
+/**
+ * Application theme: high-contrast light scheme, rounded shapes, stable for foldables and MDM-managed devices.
+ */
+@Composable
+fun SMOATheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = SmoaLightColors,
+ typography = SmoaTypography,
+ shapes = SmoaShapes,
+ content = content
+ )
+}
diff --git a/app/src/main/java/com/smoa/ui/theme/SmoaSpatial.kt b/app/src/main/java/com/smoa/ui/theme/SmoaSpatial.kt
new file mode 100644
index 0000000..ebda895
--- /dev/null
+++ b/app/src/main/java/com/smoa/ui/theme/SmoaSpatial.kt
@@ -0,0 +1,38 @@
+package com.smoa.ui.theme
+
+import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
+import androidx.compose.material3.windowsizeclass.WindowSizeClass
+import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Responsive spacing for foldables, tablets, and phones (Material window size classes).
+ */
+object SmoaSpatial {
+
+ fun horizontalPadding(windowSizeClass: WindowSizeClass): Dp =
+ when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Compact -> 16.dp
+ WindowWidthSizeClass.Medium -> 24.dp
+ WindowWidthSizeClass.Expanded -> 32.dp
+ else -> 20.dp
+ }
+
+ fun verticalPadding(windowSizeClass: WindowSizeClass): Dp =
+ when (windowSizeClass.heightSizeClass) {
+ WindowHeightSizeClass.Compact -> 12.dp
+ WindowHeightSizeClass.Medium -> 16.dp
+ WindowHeightSizeClass.Expanded -> 24.dp
+ else -> 14.dp
+ }
+
+ /** Max content width on large displays for readable line length (regulatory / long-form readability). */
+ fun contentMaxWidth(windowSizeClass: WindowSizeClass): Dp =
+ when (windowSizeClass.widthSizeClass) {
+ WindowWidthSizeClass.Compact -> Dp.Unspecified
+ WindowWidthSizeClass.Medium -> 720.dp
+ WindowWidthSizeClass.Expanded -> 1080.dp
+ else -> 640.dp
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..4804151
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..f6e3a23
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_module_placeholder.xml b/app/src/main/res/drawable/ic_module_placeholder.xml
new file mode 100644
index 0000000..6fe6ff4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_module_placeholder.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_smo_mark.xml b/app/src/main/res/drawable/ic_smo_mark.xml
new file mode 100644
index 0000000..80ee1bd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_smo_mark.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index 7d5f2a7..2a6fee8 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,6 +1,6 @@
-
-
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index 7d5f2a7..2a6fee8 100644
--- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,6 +1,6 @@
-
-
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d49578c..75822ed 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -3,6 +3,54 @@
SMOA
+ Sign in
+ Sign in with organization (SSO)
+ Organization sign-in did not complete. Try again or use local credentials.
+ Sign out
+ Log out
+ Log out?
+ You will need to sign in again to use SMOA.
+ User settings
+ Account details and session.
+ Account
+ User ID
+ Display name
+ Role
+ Unit
+ App info & permissions
+ User ID
+ Password
+ Continue
+ Back
+ Secure mobile operations
+ Sign in with your credentials, second factor, and biometric verification.
+ Enter a user ID (at least 3 characters).
+ Enter a password (at least 4 characters).
+ Second factor
+ Enter the 6-digit code from your authenticator. Demo code: 123456.
+ One-time code
+ Enter all 6 digits.
+ Invalid code. Try again.
+ Verify code
+ Complete sign-in using your enrolled biometric.
+ A system prompt will open. You can also tap the button below to try again.
+ Try biometric again
+ No biometric or device PIN/pattern/password is available. Enroll one in system security settings.
+ Biometric verification did not match.
+ Welcome to SMOA
+ This app handles sensitive workflows. Complete onboarding once on this device.
+ Strong sign-in
+ Every session requires a password, a one-time code (MFA), and biometric confirmation.
+ Default launcher (optional)
+ From Home you can open system settings to set SMOA as the default home app if your deployment allows it.
+ Next
+ Get started
+ Home
+ Open a module or configure this app as your default launcher.
+ Orders (documents)
+ Evidence
+ Reports
+ Set as default home app
Enter PIN
Biometric Authentication Required
Fingerprint Required
@@ -23,6 +71,40 @@
Secure Meetings
Controlled Browser
+
+ OFFICIAL USE ONLY
+ CONTROLLED UNCLASSIFIED
+ Screen capture and recording are blocked on this screen (FLAG_SECURE). VPN and connectivity status appear in the app bar.
+ Use only as authorized. Minimum touch targets follow platform accessibility guidance.
+ Device security
+ Biometric sign-in, encrypted preferences, and clipboard clear on log out (Android 9+) reduce data exposure between sessions.
+ Managed deployments: pair with MDM VPN profiles, hosted client config (SMOA_CONFIG_URL), and backend API keys per tenant.
+ Module illustration placeholder
+ Tap to open
+ Open navigation menu
+ Session locked
+ Biometric unlock is required after the app was in the background beyond your policy timeout.
+ Unlock
+ Enterprise & device posture
+ Build classification label
+ Background lock timeout
+ Disabled (0)
+ TLS certificate pinning
+ Active
+ Inactive
+ OIDC / OAuth
+ Configured
+ Not configured
+ Discovery: %1$s
+ Bearer token in secure store
+ Present
+ None
+ Biometric-gated AES key
+ Initialized
+ Not initialized
+ Run Play Integrity
+ Prepare biometric key
+
Lock
Unlock
diff --git a/app/src/test/java/com/smoa/api/BackendPullAPITest.kt b/app/src/test/java/com/smoa/api/BackendPullAPITest.kt
new file mode 100644
index 0000000..bf35042
--- /dev/null
+++ b/app/src/test/java/com/smoa/api/BackendPullAPITest.kt
@@ -0,0 +1,68 @@
+package com.smoa.api
+
+import com.smoa.core.common.Result
+import kotlinx.coroutines.runBlocking
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import retrofit2.Response
+
+class BackendPullAPITest {
+ private val service = BackendPullAPI { Pair(FakeBackendPullApiService(), "key-123") }
+
+ @Test
+ fun `pullDirectory returns response bytes`() = runBlocking {
+ val result = service.pullDirectory("unit-1")
+
+ assertTrue(result is Result.Success)
+ assertArrayEquals("directory-payload".toByteArray(), (result as Result.Success).data)
+ }
+
+ @Test
+ fun `pullCredentialDetail returns json bytes`() = runBlocking {
+ val result = service.pullCredentialDetail("c1")
+
+ assertTrue(result is Result.Success)
+ val text = (result as Result.Success).data.toString(Charsets.UTF_8)
+ assertTrue(text.contains("c1"))
+ }
+
+ @Test
+ fun `pullOrders returns error on http failure`() = runBlocking {
+ val failing = BackendPullAPI { Pair(FakeBackendPullApiService(failRequests = true), "key-123") }
+
+ val result = failing.pullOrders(null, 100, null)
+
+ assertTrue(result is Result.Error)
+ }
+
+ private class FakeBackendPullApiService(
+ private val failRequests: Boolean = false
+ ) : BackendPullApiService {
+ override suspend fun getDirectory(unit: String?, xUnit: String?, apiKey: String?) =
+ response("directory-payload")
+
+ override suspend fun getOrders(since: Long?, limit: Int, jurisdiction: String?, xUnit: String?, apiKey: String?) =
+ response("orders-payload")
+
+ override suspend fun getEvidence(since: Long?, limit: Int, caseNumber: String?, apiKey: String?) =
+ response("evidence-payload")
+
+ override suspend fun getCredentials(since: Long?, limit: Int, holderId: String?, apiKey: String?) =
+ response("credentials-payload")
+
+ override suspend fun getCredential(credentialId: String, apiKey: String?) =
+ response("""{"credentialId":"$credentialId","holderId":"h","credentialType":"piv_pki","issuer":null,"issuedAt":1,"expiresAt":null,"payloadJson":"{}","updatedAt":2}""")
+
+ override suspend fun getReports(since: Long?, limit: Int, apiKey: String?) =
+ response("reports-payload")
+
+ private fun response(body: String): Response {
+ if (failRequests) {
+ return Response.error(500, "boom".toResponseBody())
+ }
+ return Response.success(body.toResponseBody())
+ }
+ }
+}
diff --git a/app/src/test/java/com/smoa/api/BackendSyncAPITest.kt b/app/src/test/java/com/smoa/api/BackendSyncAPITest.kt
new file mode 100644
index 0000000..2cc9f2a
--- /dev/null
+++ b/app/src/test/java/com/smoa/api/BackendSyncAPITest.kt
@@ -0,0 +1,98 @@
+package com.smoa.api
+
+import com.smoa.api.dto.SyncResponseDto
+import com.smoa.core.common.Result
+import kotlinx.coroutines.runBlocking
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import okhttp3.ResponseBody.Companion.toResponseBody
+import retrofit2.Response
+import java.util.Base64
+
+class BackendSyncAPITest {
+ private val fakeApi = FakeBackendSyncApiService()
+ private val service = BackendSyncAPI { Pair(fakeApi, "key-123") }
+
+ @Test
+ fun `syncOrder maps backend success response`() = runBlocking {
+ val result = service.syncOrder("""{"id":"order-1"}""".toByteArray())
+
+ assertTrue(result is Result.Success)
+ val response = (result as Result.Success).data
+ assertTrue(response.success)
+ assertEquals("order-1", response.itemId)
+ assertArrayEquals("remote-order-1".toByteArray(), response.remoteData)
+ assertEquals("synced", response.message)
+ }
+
+ @Test
+ fun `deleteOrder maps backend delete response`() = runBlocking {
+ val result = service.deleteOrder("order-1")
+
+ assertTrue(result is Result.Success)
+ assertEquals("order-1", (result as Result.Success).data.itemId)
+ }
+
+ @Test
+ fun `syncOrder returns error for non successful response`() = runBlocking {
+ val failing = BackendSyncAPI { Pair(FakeBackendSyncApiService(failRequests = true), "key-123") }
+
+ val result = failing.syncOrder("""{"id":"order-1"}""".toByteArray())
+
+ assertTrue(result is Result.Error)
+ }
+
+ private class FakeBackendSyncApiService(
+ private val failRequests: Boolean = false
+ ) : BackendSyncApiService {
+ override suspend fun syncDirectory(body: RequestBody, apiKey: String?): Response =
+ response("directory")
+
+ override suspend fun syncOrder(body: RequestBody, apiKey: String?): Response =
+ response("order-1")
+
+ override suspend fun syncEvidence(body: RequestBody, apiKey: String?): Response =
+ response("evidence")
+
+ override suspend fun syncCredential(body: RequestBody, apiKey: String?): Response =
+ response("credential")
+
+ override suspend fun syncReport(body: RequestBody, apiKey: String?): Response =
+ response("report")
+
+ override suspend fun deleteDirectory(id: String, apiKey: String?): Response =
+ response(id)
+
+ override suspend fun deleteOrder(orderId: String, apiKey: String?): Response =
+ response(orderId)
+
+ override suspend fun deleteEvidence(evidenceId: String, apiKey: String?): Response =
+ response(evidenceId)
+
+ override suspend fun deleteCredential(credentialId: String, apiKey: String?): Response =
+ response(credentialId)
+
+ override suspend fun deleteReport(reportId: String, apiKey: String?): Response =
+ response(reportId)
+
+ private fun response(id: String): Response {
+ if (failRequests) {
+ return Response.error(500, "boom".toResponseBody("text/plain".toMediaType()))
+ }
+ return Response.success(
+ SyncResponseDto(
+ success = true,
+ itemId = id,
+ serverTimestamp = 1234L,
+ conflict = true,
+ remoteDataBase64 = Base64.getEncoder().encodeToString("remote-$id".toByteArray()),
+ message = "synced"
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt b/app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt
new file mode 100644
index 0000000..e35d533
--- /dev/null
+++ b/app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt
@@ -0,0 +1,37 @@
+package com.smoa.network
+
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class CertificatePinnerFactoryTest {
+
+ @Test
+ fun build_ignoresInvalidPins() {
+ assertNull(CertificatePinnerFactory.build("api.example.com", ""))
+ assertNull(CertificatePinnerFactory.build("api.example.com", "not-a-pin"))
+ }
+
+ @Test
+ fun build_addsValidPins() {
+ val pinner = CertificatePinnerFactory.build(
+ "api.example.com",
+ "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=,sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
+ )
+ assertNotNull(pinner)
+ }
+
+ @Test
+ fun buildFromMultiSpec_parsesHosts() {
+ val pinner = CertificatePinnerFactory.buildFromMultiSpec(
+ "api.example.com|sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;" +
+ "cdn.example.com|sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
+ )
+ assertNotNull(pinner)
+ }
+
+ @Test
+ fun buildFromMultiSpec_rejectsMalformed() {
+ assertNull(CertificatePinnerFactory.buildFromMultiSpec("nohostseparator"))
+ }
+}
diff --git a/app/src/test/java/com/smoa/security/SessionLockLogicTest.kt b/app/src/test/java/com/smoa/security/SessionLockLogicTest.kt
new file mode 100644
index 0000000..264db58
--- /dev/null
+++ b/app/src/test/java/com/smoa/security/SessionLockLogicTest.kt
@@ -0,0 +1,41 @@
+package com.smoa.security
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SessionLockLogicTest {
+
+ @Test
+ fun noLockWhenTimeoutDisabled() {
+ assertFalse(
+ SessionLockLogic.shouldLockAfterBackground(
+ elapsedBackgroundMs = 999_999L,
+ timeoutMs = 0L,
+ sessionActive = true
+ )
+ )
+ }
+
+ @Test
+ fun noLockWithoutSession() {
+ assertFalse(
+ SessionLockLogic.shouldLockAfterBackground(
+ elapsedBackgroundMs = 999_999L,
+ timeoutMs = 60_000L,
+ sessionActive = false
+ )
+ )
+ }
+
+ @Test
+ fun locksWhenElapsedExceedsTimeout() {
+ assertTrue(
+ SessionLockLogic.shouldLockAfterBackground(
+ elapsedBackgroundMs = 60_000L,
+ timeoutMs = 60_000L,
+ sessionActive = true
+ )
+ )
+ }
+}
diff --git a/backend/README.md b/backend/README.md
index d9f7437..770f739 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -6,6 +6,7 @@ Ground-up backend with REST APIs for the **Secure Mobile Operations Application
- **JDK 17**
- **Gradle 8.x** (wrapper included in repo root; run from `backend/` with `../gradlew` or install Gradle)
+- **Production DB:** PostgreSQL 14+ recommended. The PostgreSQL JDBC driver is on the classpath; set `SPRING_DATASOURCE_URL`, `SPRING_DATASOURCE_USERNAME`, `SPRING_DATASOURCE_PASSWORD` (or `spring.datasource.*`) and run with `SPRING_PROFILES_ACTIVE=prod`.
## Quick Start
@@ -107,14 +108,26 @@ Sync and delete operations are **audit logged** (resource type, id, operation, s
## Connecting the Android app
-1. Point the app’s sync base URL to this backend (e.g. `http://:8080`).
-2. Implement a real `SyncAPI` (e.g. with Retrofit) that:
+Gradle (examples): **`-Psmoa.backend.baseUrl=`**, **`-Psmoa.api.key=`**, **`-Psmoa.config.url=`** (hosted JSON; load is **non-blocking** — Retrofit resolves URL on each call after prefs update), **`-Psmoa.stun.urls=`** / **`-Psmoa.signaling.urls=`**, **`-Psmoa.browser.vpnEnforced=true`** (in-app browser requires VPN when enabled).
+
+1. Point the app’s sync base URL to this backend (e.g. `http://:8080`), or rely on hosted config — see `docs/examples/smoa-client-config.example.json`.
+2. Use **`CredentialSyncRequestDto`** (or an equivalent map) when queueing credential sync — see `core/common/.../CredentialSyncRequestDto.kt`.
+3. Implement a real `SyncAPI` (e.g. with Retrofit) that:
- Serializes domain models to JSON matching the backend DTOs (`DirectorySyncRequest`, `OrderSyncRequest`, etc.).
- POSTs to `/api/v1/sync/directory`, `/api/v1/sync/order`, etc.
- Parses `SyncResponse` (and handles `conflict` / `remoteData` when present).
Request DTOs align with the app’s directory, order, evidence, report, and credential concepts; field names and types are chosen for easy mapping from the mobile side.
+## Deployment and client configuration
+
+- **Proxmox LXC layout, FQDNs, and client JSON contract:** [docs/LXC-PROXMOX-CONTAINERS.md](docs/LXC-PROXMOX-CONTAINERS.md)
+- **Example client config** (for CI / static hosting): [docs/examples/smoa-client-config.example.json](docs/examples/smoa-client-config.example.json)
+
+## Identity templates (Complete Credential alignment)
+
+Credential sync types and mobile barcode templates are aligned with the **Complete Credential** shared schema (`CredentialRef.domain`) and SMOA formats (ICAO 9303, AAMVA, MIL-STD-129). See [../docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md](../docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md) and backend `SmoaCredentialType`.
+
## Gap analysis and roadmap
See [docs/BACKEND-GAPS-AND-ROADMAP.md](docs/BACKEND-GAPS-AND-ROADMAP.md) for a full review: what's covered, completed gaps (delete sync, pull/GET, enum validation, rate limiting, audit, tests, Dockerfile), and optional follow-ups (prod profile, unit/tenant scoping, migrations).
diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts
index aa441b0..8535d40 100644
--- a/backend/build.gradle.kts
+++ b/backend/build.gradle.kts
@@ -1,9 +1,9 @@
plugins {
- kotlin("jvm") version "1.9.20"
- kotlin("plugin.spring") version "1.9.20"
- kotlin("plugin.jpa") version "1.9.20"
- id("org.springframework.boot") version "3.2.2"
- id("io.spring.dependency-management") version "1.1.4"
+ id("org.jetbrains.kotlin.jvm")
+ id("org.jetbrains.kotlin.plugin.spring")
+ id("org.jetbrains.kotlin.plugin.jpa")
+ id("org.springframework.boot")
+ id("io.spring.dependency-management")
}
group = "com.smoa"
@@ -14,9 +14,7 @@ java {
targetCompatibility = JavaVersion.VERSION_17
}
-repositories {
- mavenCentral()
-}
+// Repositories: use root settings.gradle.kts dependencyResolutionManagement (FAIL_ON_PROJECT_REPOS).
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
@@ -32,8 +30,9 @@ dependencies {
// Auth
implementation("org.springframework.boot:spring-boot-starter-security")
- // Database (H2 for development; switch to PostgreSQL in production)
+ // Database: H2 for dev/test; PostgreSQL driver for production (activate via spring.datasource.*)
runtimeOnly("com.h2database:h2")
+ runtimeOnly("org.postgresql:postgresql")
implementation("org.flywaydb:flyway-core")
testImplementation("org.springframework.boot:spring-boot-starter-test")
diff --git a/backend/docs/BACKEND-GAPS-AND-ROADMAP.md b/backend/docs/BACKEND-GAPS-AND-ROADMAP.md
index 5e18d70..f29b4ec 100644
--- a/backend/docs/BACKEND-GAPS-AND-ROADMAP.md
+++ b/backend/docs/BACKEND-GAPS-AND-ROADMAP.md
@@ -45,10 +45,10 @@ The backend implements the **sync contract** expected by the mobile app (POST sy
- **Gap:** Backend returns remoteData as base64; client must decode.
- **Done:** Documented in OpenAPI description that remoteData is base64-encoded JSON when conflict=true.
-### 5. **Production and ops**
+### 5. **Production and ops** ✅ Done (baseline)
-- **Gap:** H2 console enabled in all profiles; no explicit prod profile with console off and stricter settings.
-- **Recommendation:** Add application-prod.yml: disable H2 console, set logging, optionally require API key. Document PostgreSQL (or other DB) and env vars.
+- **Done:** `application-prod.yml` disables H2 console, sets `ddl-auto: validate`, Flyway on, stricter logging.
+- **Done:** `org.postgresql:postgresql` on the runtime classpath; set `SPRING_DATASOURCE_*` (see `application-prod.yml` and backend README) for PostgreSQL. Dev/test keep H2 unless overridden.
### 6. **Rate limiting** ✅ Done
@@ -63,7 +63,7 @@ The backend implements the **sync contract** expected by the mobile app (POST sy
### 8. **Tests** ✅ Done
- **Gap:** No backend unit or integration tests.
-- **Done:** DirectorySyncServiceTest (create, conflict/remoteData, delete); GlobalExceptionHandlerTest (500); SyncControllerIntegrationTest (POST valid/invalid, health); application-test.yml (H2 in-memory, rate limit off); mockk for unit tests.
+- **Done:** DirectorySyncServiceTest (create, conflict/remoteData, delete); GlobalExceptionHandlerTest (500); SyncControllerIntegrationTest (directory, credential type validation, health); application-test.yml (H2 in-memory, rate limit off); mockk for unit tests.
### 9. **Ids and authorization**
@@ -79,10 +79,11 @@ The backend implements the **sync contract** expected by the mobile app (POST sy
## Optional improvements
-- **Pagination:** For any future GET list endpoints, use page/size or limit/offset and document in OpenAPI.
-- **ETag / If-None-Match:** For GET-by-id or list endpoints, support caching with ETag.
-- **Request ID:** Add a filter to assign and log a request ID for tracing.
+- **Pagination:** Pull endpoints use `limit`/`since`; extend with cursor or page/size if lists grow very large.
+- **ETag / If-None-Match:** ✅ `ShallowEtagHeaderFilter` on `/api/v1/**` (see `WebConfig`).
+- **Request ID:** ✅ `RequestIdFilter` assigns/propagates `X-Request-Id`.
- **API versioning:** Keep /api/v1; when introducing breaking changes, add /api/v2 and document deprecation.
+- **Credential types:** ✅ `@Pattern` on `CredentialSyncRequest.credentialType` (`SmoaCredentialType.CREDENTIAL_TYPE_PATTERN`).
---
@@ -102,4 +103,4 @@ The backend implements the **sync contract** expected by the mobile app (POST sy
## Summary
-The backend is **ready for mobile sync** with: push and **delete** sync, **pull/GET** endpoints, **conflict handling**, **enum validation**, **rate limiting**, **audit logging**, **tests**, and a **Dockerfile**. Remaining optional work: **prod profile and DB** (PostgreSQL, H2 console off), **unit/tenant scoping** (filter by unit from API key or header), and **migrations** (Flyway/Liquibase with ddl-auto: validate).
+The backend is **ready for mobile sync** with: push and **delete** sync, **pull/GET** endpoints (with **ETag**), **conflict handling**, **enum validation** (including **credential types**), **rate limiting**, **audit logging**, **request IDs**, **tests**, **Dockerfile**, **prod profile**, **PostgreSQL driver**, and **Flyway** with `ddl-auto: validate` in prod. Remaining: **strong tenant isolation** (per-tenant credentials, RLS, or JWT claims) — see `docs/security/TENANT-THREAT-MODEL.md`.
diff --git a/backend/docs/LXC-PROXMOX-CONTAINERS.md b/backend/docs/LXC-PROXMOX-CONTAINERS.md
new file mode 100644
index 0000000..8e7020c
--- /dev/null
+++ b/backend/docs/LXC-PROXMOX-CONTAINERS.md
@@ -0,0 +1,168 @@
+# Proxmox LXC layout for SMOA backend and client endpoint mapping
+
+This document describes **Linux containers (LXC)** on a **Proxmox VE guest VM** used to host all SMOA backend-related components, how **FQDNs** map to those services, and a **JSON contract** operators can use as the canonical list of endpoints and settings for client builds or a future runtime bootstrap.
+
+For **hardware sizing** of the same components (VM-level), see [../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md](../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md). For API behavior, see [../README.md](../README.md).
+
+---
+
+## 1. Design goals
+
+- **Isolation:** Separate OS instances for edge (TLS, static files), API (Spring Boot), and database (PostgreSQL).
+- **Clear DNS:** One stable FQDN per outward-facing role; internal-only names for DB and API when the edge reverse-proxies to them.
+- **Client contract:** A single JSON document per environment (e.g. production, staging) that lists base URLs and optional WebRTC-related settings, aligned with Android `BuildConfig` fields today (`SMOA_BACKEND_BASE_URL`, `SMOA_API_KEY`, `SMOA_STUN_URLS`, `SMOA_SIGNALING_URLS`).
+
+---
+
+## 2. Recommended LXC inventory
+
+Run these on a single Proxmox **VM** (the “infrastructure VM”) or spread across multiple VMs if you need stronger isolation. Each row is one **unprivileged LXC** (recommended) on Debian 12 or Ubuntu 22.04 LTS templates.
+
+| CT ID (example) | Hostname (internal) | Role | Outward FQDN (example) | Listens (internal) | Notes |
+|-----------------|---------------------|------|-------------------------|---------------------|--------|
+| **100** | `smoa-edge` | Reverse proxy + TLS + config CDN | `api.smoa.example.gov` (API), `config.smoa.example.gov` (JSON) | `443` (public), upstream to API | Nginx, Caddy, or Traefik; terminates TLS; serves `/.well-known/` or `/smoa/` static JSON. |
+| **101** | `smoa-api` | Spring Boot `smoa-backend` | (none public; only via edge) | `8080` → proxy to `http://smoa-api.lan:8080` | `SPRING_PROFILES_ACTIVE=prod`, `SMOA_API_KEY`, JDBC URL to DB container. |
+| **102** | `smoa-db` | PostgreSQL 15+ | (none public) | `5432` (LAN only) | Database for production; Flyway migrations from the backend JAR. |
+| **103** | `smoa-turn` | Coturn or equivalent TURN | `turn.smoa.example.gov` | `3478` (UDP/TCP), TLS if used | Optional; for WebRTC media relay when not using a third-party TURN. |
+| **104** | `smoa-signal` | Signaling server (if self-hosted) | `signal.smoa.example.gov` | `443` or app-specific | Optional; only if you host signaling instead of a SaaS or peer mesh. |
+
+**Minimal production set:** **100 (edge)**, **101 (api)**, **102 (db)**. Add **103/104** when meetings/WebRTC are anchored to your infra.
+
+**Small lab / pilot:** One LXC running API + PostgreSQL + Nginx (all-in-one) is acceptable; split into the table above before production traffic or compliance review.
+
+---
+
+## 3. Resource hints per container
+
+| Container | vCPU | RAM | Root disk | Data volume |
+|-----------|------|-----|-----------|-------------|
+| `smoa-edge` | 1 | 512 MiB–1 GiB | 8–16 GiB | Optional: ACME cert store, access logs |
+| `smoa-api` | 2–4 | 2–4 GiB | 16 GiB | Optional: app logs if not shipped to syslog |
+| `smoa-db` | 2 | 2–4 GiB | 16 GiB | **Dedicated:** PostgreSQL data (SSD-backed) |
+| `smoa-turn` | 2–4 | 1–2 GiB | 10 GiB | Logs; sizing scales with concurrent sessions |
+| `smoa-signal` | 1–2 | 1–2 GiB | 10 GiB | App-specific |
+
+Tune using [PROXMOX-VE-TEMPLATE-REQUIREMENTS.md](../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md).
+
+---
+
+## 4. Networking on Proxmox
+
+1. **Bridge:** Attach all LXCs to the same VM bridge (e.g. `vmbr0` inside the guest, or a Proxmox bridge on the host with a VLAN per tenant).
+2. **DNS (internal):** Resolve `smoa-api.lan`, `smoa-db.lan` (or your internal suffix) to static LXC IPs. The edge proxies to `http://smoa-api.lan:8080` only from the edge host.
+3. **Firewall:** From the internet, allow **443** (and **3478** if TURN is public). **Do not** publish PostgreSQL or raw API :8080 to untrusted networks.
+4. **TLS:** Issue certificates on `smoa-edge` (Let’s Encrypt internal ACME or org PKI) for every public FQDN.
+
+---
+
+## 5. FQDN → service mapping (operator checklist)
+
+Use this table when creating DNS records and reverse-proxy `server_name` / upstream blocks.
+
+| FQDN | Points to | Backend / purpose |
+|------|-----------|-------------------|
+| `api.smoa.example.gov` | `smoa-edge` public IP | Proxy `location /` → `http://smoa-api.lan:8080` (Spring Boot context path `/`). Health: `GET /health`, sync: `/api/v1/...`. |
+| `config.smoa.example.gov` | `smoa-edge` public IP | Static file or small JSON generator: **client config** (see §6). Prefer path such as `https://config.smoa.example.gov/smoa/client-config.json`. |
+| `turn.smoa.example.gov` | `smoa-turn` (or edge with UDP pass-through) | TURN `urls` for ICE (often `turn:turn.smoa.example.gov:3478?transport=udp`). |
+| `signal.smoa.example.gov` | `smoa-signal` | WebSocket/WebRTC signaling base URL(s) if applicable. |
+
+**Android app today:** Retrofit uses `SMOA_BACKEND_BASE_URL`, which should be the **public API base**, e.g. `https://api.smoa.example.gov/` (trailing slash optional at build; the app normalizes). See `app/build.gradle.kts` and `AppModule.kt`.
+
+---
+
+## 6. Client configuration JSON
+
+### 6.1 Purpose
+
+- **Operations:** Single file per environment describing which FQDNs clients should use.
+- **Build pipeline:** CI reads this JSON (or a copy in git) and passes Gradle properties:
+ `-Psmoa.backend.baseUrl=...` `-Psmoa.api.key=...` `-Psmoa.stun.urls=...` `-Psmoa.signaling.urls=...`
+- **Future:** The app could fetch this URL at first launch (bootstrap) and cache settings; that is not implemented in the current codebase—this document defines the **contract** so you can implement it or generate `BuildConfig` consistently.
+
+### 6.2 Security notes
+
+- **Do not** put long-lived API keys in a **world-readable** JSON on the public internet unless your threat model accepts it (e.g. key is only a device enrollment token with tight scope). Prefer: public JSON without secrets + **MDM / enterprise** delivery of `api_key`, or short-lived tokens from an identity service.
+- Serve `client-config.json` over **HTTPS** only.
+- Optionally add a **`config_signature`** (JWS) or host the file on object storage with signed URLs.
+
+### 6.3 Schema (informal)
+
+| Field | Type | Required | Maps to Android (today) | Description |
+|-------|------|----------|-------------------------|-------------|
+| `schema_version` | string | yes | — | e.g. `"1.0"`. Bump when fields change. |
+| `environment` | string | yes | — | e.g. `"production"`, `"staging"`. |
+| `api_base_url` | string (URL) | yes | `SMOA_BACKEND_BASE_URL` | Public REST base, e.g. `https://api.smoa.example.gov` (no trailing slash required). |
+| `api_key` | string \| null | no | `SMOA_API_KEY` | Omit or null if auth is device-only or injected elsewhere. |
+| `stun_urls` | string | no | `SMOA_STUN_URLS` | Comma-separated STUN URLs for ICE, e.g. `stun:stun.l.google.com:19302`. |
+| `signaling_urls` | string | no | `SMOA_SIGNALING_URLS` | Comma-separated signaling/WebSocket bases if used by communications module. |
+| `turn_urls` | string | no | (future / custom) | Comma-separated TURN URLs, e.g. `turn:turn.smoa.example.gov:3478?transport=udp`. |
+| `well_known_health_url` | string | no | — | Optional; e.g. `https://api.smoa.example.gov/health` for monitoring scripts. |
+| `openapi_url` | string | no | — | Optional; e.g. `https://api.smoa.example.gov/swagger-ui.html` for admins. |
+
+### 6.4 Example: `client-config.json`
+
+Host at a stable URL, e.g. `https://config.smoa.example.gov/smoa/client-config.json`. A copy-checked example lives in this repo at [examples/smoa-client-config.example.json](examples/smoa-client-config.example.json).
+
+```json
+{
+ "schema_version": "1.0",
+ "environment": "production",
+ "api_base_url": "https://api.smoa.example.gov",
+ "api_key": null,
+ "stun_urls": "stun:stun.l.google.com:19302",
+ "signaling_urls": "wss://signal.smoa.example.gov/ws",
+ "turn_urls": "turn:turn.smoa.example.gov:3478?transport=udp",
+ "well_known_health_url": "https://api.smoa.example.gov/health",
+ "openapi_url": "https://api.smoa.example.gov/swagger-ui.html"
+}
+```
+
+**Gradle mapping example (CI or local):**
+
+```text
+-Psmoa.backend.baseUrl=https://api.smoa.example.gov
+-Psmoa.api.key=
+-Psmoa.stun.urls=stun:stun.l.google.com:19302
+-Psmoa.signaling.urls=wss://signal.smoa.example.gov/ws
+```
+
+---
+
+## 7. `smoa-api` environment variables (reference)
+
+Typical production settings for LXC **101** (adjust names to match your JDBC host):
+
+| Variable | Example |
+|----------|---------|
+| `SPRING_PROFILES_ACTIVE` | `prod` |
+| `SERVER_PORT` | `8080` |
+| `SMOA_API_KEY` | `` |
+| `SMOA_CORS_ORIGINS` | `https://trusted-web-origin.example.gov` |
+| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://smoa-db.lan:5432/smoa` |
+| `SPRING_DATASOURCE_USERNAME` | `smoa` |
+| `SPRING_DATASOURCE_PASSWORD` | `` |
+| `SPRING_DATASOURCE_DRIVER_CLASS_NAME` | `org.postgresql.Driver` |
+
+Ensure `application-prod.yml` (or overrides) use PostgreSQL and `ddl-auto: validate` with Flyway, per [../README.md](../README.md).
+
+---
+
+## 8. Quick Proxmox LXC creation notes
+
+1. Create CT from Debian 12 / Ubuntu 22.04 template; enable **nesting** only if you run Docker inside the CT (not required for JAR + systemd).
+2. **Static DHCP or CT config:** Fixed IPv4 for `smoa-api`, `smoa-db`, `smoa-edge`.
+3. **Backups:** Include `smoa-db` data volume in Proxmox backup jobs; test restore.
+4. **Updates:** Patch guest OS per org baseline; restart API after JVM or jar updates.
+
+---
+
+## 9. Related paths in this repository
+
+| Topic | Location |
+|-------|----------|
+| Backend API and env | [../README.md](../README.md) |
+| Docker image (optional alternative to bare JAR in LXC) | [../Dockerfile](../Dockerfile) |
+| Proxmox VM sizing | [../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md](../../docs/infrastructure/PROXMOX-VE-TEMPLATE-REQUIREMENTS.md) |
+| Android backend URL wiring | `app/build.gradle.kts`, `app/.../di/AppModule.kt` |
+
+This file is the **LXC + FQDN + JSON contract** reference; keep it updated when you add services (e.g. object storage, IdP callbacks, or push notification gateways).
diff --git a/backend/docs/examples/smoa-client-config.example.json b/backend/docs/examples/smoa-client-config.example.json
new file mode 100644
index 0000000..7a313b0
--- /dev/null
+++ b/backend/docs/examples/smoa-client-config.example.json
@@ -0,0 +1,14 @@
+{
+ "schema_version": "1.0",
+ "environment": "production",
+ "api_base_url": "https://api.smoa.example.gov",
+ "api_key": null,
+ "stun_urls": "stun:stun.l.google.com:19302",
+ "signaling_urls": "wss://signal.smoa.example.gov/ws",
+ "turn_urls": "turn:turn.smoa.example.gov:3478?transport=udp",
+ "well_known_health_url": "https://api.smoa.example.gov/health",
+ "openapi_url": "https://api.smoa.example.gov/swagger-ui.html",
+ "tls_pin_spec": "api.smoa.example.gov|sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "classification_watermark_primary": "OFFICIAL USE ONLY",
+ "classification_watermark_secondary": "CONTROLLED UNCLASSIFIED"
+}
diff --git a/backend/src/main/kotlin/com/smoa/backend/api/CredentialController.kt b/backend/src/main/kotlin/com/smoa/backend/api/CredentialController.kt
new file mode 100644
index 0000000..780017b
--- /dev/null
+++ b/backend/src/main/kotlin/com/smoa/backend/api/CredentialController.kt
@@ -0,0 +1,41 @@
+package com.smoa.backend.api
+
+import com.smoa.backend.api.dto.CredentialDetailItem
+import com.smoa.backend.api.dto.CredentialSyncRequest
+import com.smoa.backend.api.dto.SyncResponse
+import com.smoa.backend.service.CredentialSyncService
+import com.smoa.backend.service.SyncAuditService
+import io.swagger.v3.oas.annotations.Operation
+import io.swagger.v3.oas.annotations.tags.Tag
+import jakarta.validation.Valid
+import org.springframework.http.MediaType
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.*
+
+/**
+ * Authoritative credential issuance (POST) and full record retrieval (GET by id).
+ * List without payload remains on [PullController] GET /api/v1/credentials.
+ */
+@RestController
+@RequestMapping("/api/v1/credentials")
+@Tag(name = "Credentials", description = "Issue and retrieve full credential records")
+class CredentialController(
+ private val credentialSyncService: CredentialSyncService,
+ private val syncAuditService: SyncAuditService
+) {
+
+ @GetMapping("/{credentialId}")
+ @Operation(summary = "Get credential by id including payload JSON")
+ fun getCredential(@PathVariable credentialId: String): ResponseEntity {
+ val entity = credentialSyncService.getById(credentialId) ?: return ResponseEntity.notFound().build()
+ return ResponseEntity.ok(CredentialDetailItem.from(entity))
+ }
+
+ @PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE])
+ @Operation(summary = "Issue or upsert a credential (same semantics as POST /api/v1/sync/credential)")
+ fun issueCredential(@Valid @RequestBody request: CredentialSyncRequest): ResponseEntity {
+ val response = credentialSyncService.sync(request)
+ syncAuditService.log("credential", request.credentialId, "issue", response.success)
+ return ResponseEntity.ok(response)
+ }
+}
diff --git a/backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt b/backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt
new file mode 100644
index 0000000..da2c2c5
--- /dev/null
+++ b/backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt
@@ -0,0 +1,39 @@
+package com.smoa.backend.api
+
+import com.smoa.backend.api.dto.IntegrityVerifyRequest
+import com.smoa.backend.api.dto.IntegrityVerifyResponse
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RestController
+
+/**
+ * Play Integrity token verification (Android). Production wiring requires Google API credentials
+ * and nonce lifecycle; this endpoint documents the contract and returns 501 until configured.
+ */
+@RestController
+@RequestMapping("/api/v1/integrity")
+class IntegrityAttestationController {
+
+ @PostMapping("/verify")
+ fun verify(@RequestBody body: IntegrityVerifyRequest): ResponseEntity {
+ if (body.token.isBlank()) {
+ return ResponseEntity.badRequest().body(
+ IntegrityVerifyResponse(
+ verified = false,
+ message = "token is required"
+ )
+ )
+ }
+ return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(
+ IntegrityVerifyResponse(
+ verified = false,
+ message = "Play Integrity verification is not enabled on this server. " +
+ "Configure Google Play Integrity API credentials and implement DecryptIntegrityToken " +
+ "(see backend docs / pairs with Android PlayIntegrityVerifier)."
+ )
+ )
+ }
+}
diff --git a/backend/src/main/kotlin/com/smoa/backend/api/dto/CredentialDetailItem.kt b/backend/src/main/kotlin/com/smoa/backend/api/dto/CredentialDetailItem.kt
new file mode 100644
index 0000000..80a0dec
--- /dev/null
+++ b/backend/src/main/kotlin/com/smoa/backend/api/dto/CredentialDetailItem.kt
@@ -0,0 +1,28 @@
+package com.smoa.backend.api.dto
+
+import com.smoa.backend.domain.CredentialEntity
+
+/** Full credential for GET /api/v1/credentials/{credentialId} (includes presentation payload). */
+data class CredentialDetailItem(
+ val credentialId: String,
+ val holderId: String,
+ val credentialType: String,
+ val issuer: String?,
+ val issuedAt: Long?,
+ val expiresAt: Long?,
+ val payloadJson: String?,
+ val updatedAt: Long
+) {
+ companion object {
+ fun from(e: CredentialEntity) = CredentialDetailItem(
+ credentialId = e.credentialId,
+ holderId = e.holderId,
+ credentialType = e.credentialType,
+ issuer = e.issuer,
+ issuedAt = e.issuedAt,
+ expiresAt = e.expiresAt,
+ payloadJson = e.payloadJson,
+ updatedAt = e.updatedAt
+ )
+ }
+}
diff --git a/backend/src/main/kotlin/com/smoa/backend/api/dto/IntegrityVerifyRequest.kt b/backend/src/main/kotlin/com/smoa/backend/api/dto/IntegrityVerifyRequest.kt
new file mode 100644
index 0000000..9cae5b8
--- /dev/null
+++ b/backend/src/main/kotlin/com/smoa/backend/api/dto/IntegrityVerifyRequest.kt
@@ -0,0 +1,13 @@
+package com.smoa.backend.api.dto
+
+import com.fasterxml.jackson.annotation.JsonProperty
+
+data class IntegrityVerifyRequest(
+ @JsonProperty("token") val token: String,
+ @JsonProperty("nonce") val nonce: String? = null
+)
+
+data class IntegrityVerifyResponse(
+ @JsonProperty("verified") val verified: Boolean,
+ @JsonProperty("message") val message: String
+)
diff --git a/backend/src/main/kotlin/com/smoa/backend/api/dto/SyncRequest.kt b/backend/src/main/kotlin/com/smoa/backend/api/dto/SyncRequest.kt
index f1a9313..91cd426 100644
--- a/backend/src/main/kotlin/com/smoa/backend/api/dto/SyncRequest.kt
+++ b/backend/src/main/kotlin/com/smoa/backend/api/dto/SyncRequest.kt
@@ -1,5 +1,6 @@
package com.smoa.backend.api.dto
+import com.smoa.backend.domain.SmoaCredentialType
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Pattern
@@ -53,11 +54,21 @@ data class EvidenceSyncRequest(
val clientUpdatedAt: Long? = null
)
-/** Request body for credential sync. Generic payload for issued credentials. */
+/**
+ * Request body for credential sync. Generic payload for issued credentials.
+ * Prefer [com.smoa.backend.domain.SmoaCredentialType] values for [credentialType];
+ * optional payload key [com.smoa.backend.domain.SmoaCredentialType.PAYLOAD_KEY_CC_DOMAIN]
+ * mirrors Complete Credential OpenAPI CredentialRef.domain.
+ */
data class CredentialSyncRequest(
@field:NotBlank val credentialId: String,
@field:NotBlank val holderId: String,
- @field:NotBlank val credentialType: String,
+ @field:NotBlank
+ @field:Pattern(
+ regexp = SmoaCredentialType.CREDENTIAL_TYPE_PATTERN,
+ message = "credentialType must be a known template (see SmoaCredentialType / IDENTITY-TEMPLATE-ALIGNMENT.md)"
+ )
+ val credentialType: String,
val issuer: String? = null,
val issuedAt: Long? = null,
val expiresAt: Long? = null,
diff --git a/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt b/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt
index c6b9081..a911de8 100644
--- a/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt
+++ b/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
/**
- * When smoa.tenant.require-unit is true, requires X-Unit header for /api/v1/sync and /api/v1/* (pull) requests.
+ * When smoa.tenant.require-unit is true, requires X-Unit header for /api/v1/sync and other /api/v1 pull routes.
* Returns 400 if unit is required but missing.
*/
@Component
diff --git a/backend/src/main/kotlin/com/smoa/backend/config/WebConfig.kt b/backend/src/main/kotlin/com/smoa/backend/config/WebConfig.kt
index 934dc6d..21edf37 100644
--- a/backend/src/main/kotlin/com/smoa/backend/config/WebConfig.kt
+++ b/backend/src/main/kotlin/com/smoa/backend/config/WebConfig.kt
@@ -22,7 +22,7 @@ class WebConfig : WebMvcConfigurer {
.allowCredentials(if (allowedOrigins == "*") false else true)
}
- /** ETag support for GET /api/v1/*: sets ETag header and returns 304 when If-None-Match matches. */
+ /** ETag support for GET requests under /api/v1; sets ETag header and returns 304 when If-None-Match matches. */
@Bean
fun etagFilter(): FilterRegistrationBean {
val filter = ShallowEtagHeaderFilter()
diff --git a/backend/src/main/kotlin/com/smoa/backend/domain/SmoaCredentialType.kt b/backend/src/main/kotlin/com/smoa/backend/domain/SmoaCredentialType.kt
new file mode 100644
index 0000000..f3bc2cb
--- /dev/null
+++ b/backend/src/main/kotlin/com/smoa/backend/domain/SmoaCredentialType.kt
@@ -0,0 +1,44 @@
+package com.smoa.backend.domain
+
+/**
+ * Canonical `credentialType` / `credentials.credential_type` values, aligned with
+ * Complete Credential `CredentialRef.domain` and SMOA barcode templates.
+ *
+ * Keep in sync with `SmoaCredentialTemplateIds` in the Android credentials module and
+ * `docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md`.
+ */
+object SmoaCredentialType {
+ const val PIV_PKI = "piv_pki"
+ const val PAYMENT = "payment"
+ const val NFC_ACCESS = "nfc_access"
+ const val MOBILE = "mobile"
+
+ const val ICAO9303_MRTD = "icao9303_mrtd"
+ const val AAMVA_DLID = "aamva_dlid"
+ const val MIL_STD_129 = "mil_std_129"
+ const val AGENCY_BADGE = "agency_badge"
+
+ val CANONICAL: Set = setOf(
+ PIV_PKI,
+ PAYMENT,
+ NFC_ACCESS,
+ MOBILE,
+ ICAO9303_MRTD,
+ AAMVA_DLID,
+ MIL_STD_129,
+ AGENCY_BADGE
+ )
+
+ /** Legacy API / docs values; prefer [CANONICAL]. */
+ val LEGACY: Set = setOf("id", "badge", "license", "permit", "other")
+
+ val ALL_ALLOWED: Set = CANONICAL + LEGACY
+
+ /**
+ * Bean Validation [@Pattern] for [com.smoa.backend.api.dto.CredentialSyncRequest.credentialType].
+ */
+ const val CREDENTIAL_TYPE_PATTERN =
+ "^(piv_pki|payment|nfc_access|mobile|icao9303_mrtd|aamva_dlid|mil_std_129|agency_badge|id|badge|license|permit|other)$"
+
+ const val PAYLOAD_KEY_CC_DOMAIN = "completeCredentialDomain"
+}
diff --git a/backend/src/main/kotlin/com/smoa/backend/service/CredentialSyncService.kt b/backend/src/main/kotlin/com/smoa/backend/service/CredentialSyncService.kt
index d619f60..301de02 100644
--- a/backend/src/main/kotlin/com/smoa/backend/service/CredentialSyncService.kt
+++ b/backend/src/main/kotlin/com/smoa/backend/service/CredentialSyncService.kt
@@ -76,4 +76,7 @@ class CredentialSyncService(
else -> credentialRepository.findAll(page).content
}
}
+
+ fun getById(credentialId: String): CredentialEntity? =
+ credentialRepository.findById(credentialId).orElse(null)
}
diff --git a/backend/src/main/kotlin/com/smoa/backend/service/ReportSyncService.kt b/backend/src/main/kotlin/com/smoa/backend/service/ReportSyncService.kt
index 0ef9903..9587e4a 100644
--- a/backend/src/main/kotlin/com/smoa/backend/service/ReportSyncService.kt
+++ b/backend/src/main/kotlin/com/smoa/backend/service/ReportSyncService.kt
@@ -1,5 +1,6 @@
package com.smoa.backend.service
+import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.smoa.backend.api.dto.ReportSyncRequest
import com.smoa.backend.api.dto.SyncResponse
@@ -22,15 +23,26 @@ class ReportSyncService(
val existing = reportRepository.findById(request.reportId).orElse(null)
if (existing != null && request.clientUpdatedAt != null && existing.updatedAt > request.clientUpdatedAt) {
- val remoteMap = mapOf(
- "reportId" to existing.reportId,
- "reportType" to existing.reportType,
- "title" to existing.title,
- "format" to existing.format,
- "generatedDate" to existing.generatedDate,
- "generatedBy" to existing.generatedBy,
- "updatedAt" to existing.updatedAt
- )
+ val remoteMap = buildMap {
+ put("reportId", existing.reportId)
+ put("reportType", existing.reportType)
+ put("title", existing.title)
+ put("format", existing.format)
+ put("generatedDate", existing.generatedDate)
+ put("generatedBy", existing.generatedBy)
+ put("updatedAt", existing.updatedAt)
+ existing.content?.let { bytes ->
+ put("contentBase64", Base64.getEncoder().encodeToString(bytes))
+ }
+ existing.metadataJson?.trim()?.takeIf { it.isNotEmpty() }?.let { mj ->
+ runCatching {
+ put(
+ "metadata",
+ objectMapper.readValue(mj, object : TypeReference