diff --git a/README.md b/README.md
index 6718f4e..2a06eea 100644
--- a/README.md
+++ b/README.md
@@ -120,39 +120,22 @@ See the `docs/` directory for comprehensive documentation:
## Implementation Status
-### ✅ Phase 1 Critical Features - 100% Complete
+### App surfaces (aligned with feature list above)
-**Security Features:**
-- ✅ Screenshot & screen recording prevention
-- ✅ VPN integration and enforcement
-- ✅ True dual biometric authentication (PIN + Fingerprint + Facial)
-- ✅ Database encryption with SQLCipher
-- ✅ Hardware-backed key storage
+**Security (defense-in-depth, deployment-dependent):** Screen protection (e.g. `FLAG_SECURE`), VPN hooks, MFA + biometric sign-in flow, encrypted stores — validate against your org’s assessment; not a substitute for formal certification.
-**Functional Modules:**
-- ✅ Directory module (complete)
-- ✅ Browser module (complete)
-- ✅ Communications module (framework complete)
-- ✅ Meetings module (framework complete)
-- ✅ Credentials, Orders, Evidence, Reports (existing)
+**Functional modules — navigable UI + domain services:**
+Credentials, **Orders** (list/detail, workflow steps, Room), **Evidence** (list/detail, custody transfers + chain in DB), **Reports** (type/format generation via `ReportService`), **ATF** (4473 validate/submit simulation; Forms 1/4 documented stubs), **NCIC** (ORI/UCN validation + simulated query; live III requires CJIS), **Military** (demo credential issuance), **Judicial** (demo court order), **Intelligence** (compartments, NTK, protected source demo), plus Directory, Communications, Meetings, Browser — all reachable from **Home** and the drawer under **RBAC** (`core/auth`).
-**Infrastructure:**
-- ✅ Offline synchronization service
-- ✅ WebRTC framework
-- ✅ Complete dependency injection
-- ✅ Navigation framework
-- ✅ Test infrastructure (27+ test cases)
+**Infrastructure:** Hilt, Navigation Compose, offline/sync and backend contract (see `core/common`, `backend/`). WebRTC remains integration-ready, not a full production mesh.
-### Test Coverage
-- **Test Files:** 7 files
-- **Test Cases:** 27+ test cases
-- **Modules Tested:** 6 modules
-- **Coverage:** Foundation complete
+### Tests
-### Code Quality
-- ✅ Zero linter errors
-- ✅ All dependencies configured
-- ✅ Architecture patterns followed
+Unit / integration tests run via `./gradlew smoaVerify` (backend + Android unit tests + debug APK). Add module-level tests as features harden; external compliance (CJIS, eTrace, barcode jurisdictions) still needs agency-specific validation.
+
+### Code quality
+
+Project builds with the configured toolchain; treat lint and coverage as ongoing hygiene.
## License
diff --git a/TASKS.md b/TASKS.md
index f419f24..72f72db 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -1,6 +1,6 @@
# SMOA – Master task list
-All areas; **status** reflects repo state after the 2026-02 optional-task sweep. Details: [TODO.md](./TODO.md).
+All areas; **status** reflects repo state after the **2026-03** sweep (enterprise auth, OIDC, pinning, CI). Details: [TODO.md](./TODO.md).
| # | Area | Task | Status |
|---|------|------|--------|
@@ -16,44 +16,44 @@ All areas; **status** reflects repo state after the 2026-02 optional-task sweep.
| 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) |
+| A5 | Android | WebRTC PeerConnection | **Stub** — [WebRTCManager](modules/communications/src/main/java/com/smoa/modules/communications/domain/WebRTCManager.kt); optional AAR |
+| A6 | Android | Connection quality | **Done** — [NetworkEstimatesConnectionQualityMonitor](modules/communications/src/main/java/com/smoa/modules/communications/domain/NetworkEstimatesConnectionQualityMonitor.kt) |
+| A7 | Android | Screen share / file transfer | **Stub** — flags in [VideoTransport](modules/meetings/src/main/java/com/smoa/modules/meetings/domain/VideoTransport.kt) |
+| A8 | Android | SmartCardReader | **Stub** — [SmartCardReader](core/common/src/main/java/com/smoa/core/common/SmartCardReader.kt) (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`) |
+| W3 | Web | Offline queue helper | Done (`docs/web-scaffold/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) |
+| D1 | Domain | NCIC live API | **Legal gate** — CJIS; local **Room** log: [NCICQueryDatabase](modules/ncic/src/main/java/com/smoa/modules/ncic/data/NCICQueryDatabase.kt) |
+| D2 | Domain | ATF eTrace | **Legal gate**; **Room** drafts: [ATFFormDatabase](modules/atf/src/main/java/com/smoa/modules/atf/data/ATFFormDatabase.kt) |
+| D3 | Domain | eIDAS QTSP / EU trust lists | **Product gate** — stub in [EIDASService](core/eidas/src/main/java/com/smoa/core/eidas/domain/EIDASService.kt) |
+| D4 | Domain | JCA digital signatures | **Done:** [DigitalSignatureService](core/signing/src/main/java/com/smoa/core/signing/domain/DigitalSignatureService.kt) |
+| D5 | Domain | Electronic seal verify | **Done:** SHA-256 compare in [ElectronicSealService](core/signing/src/main/java/com/smoa/core/signing/domain/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 |
+| T1 | Testing | `smoaVerify` in CI | Done — [.gitea/workflows/ci.yml](.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 |
+| X2 | Build | **`./gradlew smoaVerify`** (`:backend:test`, `:app:testDebugUnitTest`, `:app:assembleDebug`) + [scripts/build-all.sh](scripts/build-all.sh) | 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) |
+| A10 | Android | `SecureTokenStore`, OIDC `BuildConfig`, session lock overlay | **Done** — [security package](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 |
+| A12 | Android | Biometric-gated AES key (Keystore) | **Done** — [BiometricSecretsVault](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt); encrypt `CryptoObject` path: **A16** |
| 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 |
+| 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; IdP-only when you remove demo UI |
+| A19 | Android | Release **R8** + pinning + Integrity + ProGuard | **Verified** — `./gradlew :app:assembleRelease` succeeds; extend per-tenant release checklists |
| 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) |
+| T5 | Testing | UI tests: session lock, user settings | **Partial** — add `@HiltAndroidTest` + Compose rules when CI has an emulator/device |
+| T6 | Testing | CI: `scripts/generate-api-docs.sh` | **Done** — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml) (step after `smoaVerify`) |
| 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 e730d85..8a17193 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,13 +1,13 @@
-# SMOA – Task status (post-sweep)
+# SMOA – Task status (living doc)
-This file tracks **what the repository delivers** versus **what remains outside the repo** (legal gates, Xcode app binary, live vendor APIs).
+This file tracks **what the repository delivers** versus **what remains outside the repo** (legal gates, Xcode app binary, live vendor APIs) and **follow-ups** that are intentionally incomplete.
-**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.
+**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**, **Android unit tests** (`:app:testDebugUnitTest`), and **debug APK** (`:app:assembleDebug`).
---
-## Completed in repository (optional + remaining)
+## Completed in repository
### Backend
- [x] Prod profile, Flyway, PostgreSQL documentation
@@ -15,15 +15,25 @@ This file tracks **what the repository delivers** versus **what remains outside
- [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
+- [x] Tests: `application-test.yml`, `:backend:test` green
+- [x] **Play Integrity API contract (stub):** `POST /api/v1/integrity/verify` returns **501** until server-side Google verification is implemented — [IntegrityAttestationController](backend/src/main/kotlin/com/smoa/backend/api/IntegrityAttestationController.kt) (see [TASKS.md](TASKS.md) **B9**)
### Android
-- [x] Sync/pull, Gson, BuildConfig backend URL + API key + STUN/signaling URLs
+- [x] Sync/pull, Gson, BuildConfig backend URL + API key + STUN/signaling URLs + optional hosted client config (`SMOA_CONFIG_URL`)
- [x] Android 16 documentation
-- [x] **Connection quality:** `NetworkEstimatesConnectionQualityMonitor` (link bandwidth from `NetworkCapabilities`)
+- [x] **Connection quality:** `NetworkEstimatesConnectionQualityMonitor` (bandwidth from `NetworkCapabilities`)
- [x] **WebRTC / screen share / file transfer:** documented stubs; `WebRTCManager` + `VideoTransport` flags (no AAR bundled)
-- [x] **SmartCardReader:** explicit no-hardware stub
+- [x] **SmartCardReader:** explicit no-hardware stub — [SmartCardReader](core/common/src/main/java/com/smoa/core/common/SmartCardReader.kt)
- [x] **Knox:** integration guide only — [KNOX-INTEGRATION.md](docs/reference/KNOX-INTEGRATION.md)
+- [x] **TLS pinning + bearer/API key:** [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt), [AuthTokenInterceptor](app/src/main/java/com/smoa/network/AuthTokenInterceptor.kt), multi-host `tls_pin_spec` / `SMOA_TLS_PIN_SPEC` — [CertificatePinnerFactory](app/src/main/java/com/smoa/network/CertificatePinnerFactory.kt)
+- [x] **OIDC (AppAuth):** discovery, authorization, token exchange, `SecureTokenStore` + token endpoint persistence — [OidcLoginCoordinator](app/src/main/java/com/smoa/security/OidcLoginCoordinator.kt), manifest `RedirectUriReceiverActivity`, Gradle `manifestPlaceholders` for redirect scheme/host
+- [x] **401 refresh:** [TokenRefreshAuthenticator](app/src/main/java/com/smoa/network/TokenRefreshAuthenticator.kt) + unauthenticated `OkHttpClient`
+- [x] **Pinning host from hosted config:** [NetworkPinningConfig.resolveBackendHost(RemoteEndpointStore)](app/src/main/java/com/smoa/network/NetworkPinningConfig.kt)
+- [x] **Session lock + device credential / biometric:** [SessionLockController](app/src/main/java/com/smoa/security/SessionLockController.kt), [SessionLockOverlay](app/src/main/java/com/smoa/ui/auth/SessionLockOverlay.kt), [BiometricAuthenticator](app/src/main/java/com/smoa/auth/BiometricAuthenticator.kt)
+- [x] **Biometric Keystore + CryptoObject path:** [BiometricSecretsVault](app/src/main/java/com/smoa/security/BiometricSecretsVault.kt)
+- [x] **Auth UX:** demo username/password/MFA + **Sign in with organization (SSO)** when OIDC BuildConfig is set — [AuthFlowHost](app/src/main/java/com/smoa/ui/auth/AuthFlowHost.kt), [MainActivity](app/src/main/java/com/smoa/MainActivity.kt)
+- [x] **Classification watermark from remote config:** [SmoaClientConfigJson](app/src/main/java/com/smoa/config/SmoaClientConfigJson.kt), [RemoteEndpointStore](app/src/main/java/com/smoa/config/RemoteEndpointStore.kt), [ClassificationWatermark](app/src/main/java/com/smoa/ui/components/SmoaChrome.kt)
+- [x] **Play Integrity client + Knox probe:** [PlayIntegrityVerifier](app/src/main/java/com/smoa/security/PlayIntegrityVerifier.kt), [KnoxEnterpriseProbe](app/src/main/java/com/smoa/security/KnoxEnterpriseProbe.kt)
### iOS
- [x] API contract + checklist + **Swift samples** — [docs/ios/README.md](docs/ios/README.md), [docs/ios/SAMPLES.md](docs/ios/SAMPLES.md)
@@ -49,27 +59,28 @@ This file tracks **what the repository delivers** versus **what remains outside
### Testing & CI
- [x] Gitea Actions: `./gradlew smoaVerify --no-daemon` — [.gitea/workflows/ci.yml](.gitea/workflows/ci.yml)
+- [x] **`bash scripts/generate-api-docs.sh`** in CI (static ReDoc under `docs/api/generated/`) — same workflow
- [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`
+- [x] Unit tests: session lock policy helper, certificate pinner factory — [SessionLockLogicTest](app/src/test/java/com/smoa/security/SessionLockLogicTest.kt), [CertificatePinnerFactoryTest](app/src/test/java/com/smoa/network/CertificatePinnerFactoryTest.kt)
+- [ ] Android **80%+ coverage** — ongoing goal
+- [ ] **Instrumented / on-device tests** for `SecureTokenStore` and other Android Keystore paths (not reliable on JVM Robolectric)
+- [ ] **UI tests** (session lock overlay, user settings enterprise card) when CI has an emulator — see [TASKS.md](TASKS.md) **T5**
### Documentation
-- [x] Backend README, IMPLEMENTATION_STATUS “next steps”, BUILD.md, TASKS.md
+- [x] Backend README, [IMPLEMENTATION_STATUS](docs/status/IMPLEMENTATION_STATUS.md) (historical snapshot; see TASKS for current feature rows), [BUILD.md](docs/development/BUILD.md), TASKS.md, this file
+
+---
+
+## Follow-ups (in repo, not “done”)
+
+| Item | Notes |
+|------|--------|
+| **B9 production** | Implement Google Play Integrity **DecryptIntegrityToken** + nonce lifecycle on server; client already posts contract via stub endpoint |
+| **A18 IdP-only** | Remove or gate demo password/MFA in `AuthFlowHost` when all users must use SSO |
+| **Refresh token + CryptoObject** | Optional: wrap plaintext refresh in keystore-backed ciphertext after OIDC; wire unwrap into refresh flow only if product policy requires it |
+| **Dynamic OkHttp rebuild** | Hosted config can change API URL at runtime; pinning host is fixed at process start unless you add client refresh (see [NetworkModule](app/src/main/java/com/smoa/di/NetworkModule.kt)) |
+| **Publish API docs** | CI generates files locally; optional: upload `docs/api/generated` to static hosting / Pages |
+| **D7 revocation** | Replace UNKNOWN stub with OCSP/CRL where policies require |
---
@@ -91,11 +102,11 @@ See [TASKS.md](./TASKS.md) rows **A9–A21**, **T4–T6**. Summary:
| Area | Repo-delivered | External / binary app |
|------|----------------|------------------------|
-| Backend | All listed | — |
-| Android | All listed except Knox binary | Knox AAR, WebRTC AAR optional |
+| Backend | Core API + tests + integrity **stub** | Full Play Integrity verification service |
+| Android | Modules + enterprise auth/networking above | 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 [TASKS.md](./TASKS.md) for the flat checklist with file pointers.
+Use [TASKS.md](./TASKS.md) for the flat checklist with stable row IDs (**A1–A21**, **B1–B9**, **T1–T6**, etc.).
diff --git a/app/src/main/java/com/smoa/ui/main/HomeScreen.kt b/app/src/main/java/com/smoa/ui/main/HomeScreen.kt
index 42fd407..4217748 100644
--- a/app/src/main/java/com/smoa/ui/main/HomeScreen.kt
+++ b/app/src/main/java/com/smoa/ui/main/HomeScreen.kt
@@ -16,12 +16,17 @@ 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.Assignment
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.Gavel
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.Policy
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -50,7 +55,7 @@ import com.smoa.ui.theme.SmoaSpatial
private data class HomeRoute(
val labelRes: Int,
val route: String,
- val module: RBACFramework.Module?,
+ val module: RBACFramework.Module,
val icon: ImageVector
)
@@ -72,10 +77,15 @@ fun HomeScreen(
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_credentials, SMOARoute.Credentials.route, RBACFramework.Module.CREDENTIALS, Icons.Default.Info),
+ HomeRoute(R.string.home_orders, SMOARoute.Orders.route, RBACFramework.Module.ORDERS, Icons.Default.Description),
+ HomeRoute(R.string.home_evidence, SMOARoute.Evidence.route, RBACFramework.Module.EVIDENCE, Icons.Default.Folder),
+ HomeRoute(R.string.home_reports, SMOARoute.Reports.route, RBACFramework.Module.REPORTS, Icons.Default.Description),
+ HomeRoute(R.string.module_atf, SMOARoute.Atf.route, RBACFramework.Module.ATF, Icons.Default.Assignment),
+ HomeRoute(R.string.module_ncic, SMOARoute.Ncic.route, RBACFramework.Module.NCIC, Icons.Default.Search),
+ HomeRoute(R.string.module_military, SMOARoute.Military.route, RBACFramework.Module.MILITARY, Icons.Default.Security),
+ HomeRoute(R.string.module_judicial, SMOARoute.Judicial.route, RBACFramework.Module.JUDICIAL, Icons.Default.Gavel),
+ HomeRoute(R.string.module_intelligence, SMOARoute.Intelligence.route, RBACFramework.Module.INTELLIGENCE, Icons.Default.Policy),
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),
@@ -84,7 +94,7 @@ fun HomeScreen(
}
val tiles = remember(userRole, rbacFramework) {
allRoutes.filter { route ->
- route.module == null || rbacFramework.canAccessModule(userRole, route.module)
+ rbacFramework.canAccessModule(userRole, route.module)
}
}
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 843e858..d476b35 100644
--- a/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt
+++ b/app/src/main/java/com/smoa/ui/navigation/NavigationDrawer.kt
@@ -1,13 +1,22 @@
package com.smoa.ui.navigation
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
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.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -34,6 +43,11 @@ fun NavigationDrawer(
val currentUser by userSession.currentUser.collectAsState()
val userRole = currentUser?.role ?: RBACFramework.Role.GUEST
+ fun go(route: String) {
+ navController.navigateSmoa(route)
+ onDrawerDismiss()
+ }
+
ModalDrawerSheet(
modifier = modifier.width(280.dp)
) {
@@ -42,16 +56,15 @@ fun NavigationDrawer(
.fillMaxHeight()
.padding(16.dp)
) {
- // User info header
Text(
text = currentUser?.userName ?: "Guest",
- style = MaterialTheme.typography.headlineSmall,
+ style = androidx.compose.material3.MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "Role: ${userRole.name}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
+ color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 24.dp)
)
@@ -60,61 +73,88 @@ fun NavigationDrawer(
NavigationDrawerItem(
label = { Text(stringResource(R.string.home_title)) },
selected = navController.currentDestination?.route == SMOARoute.Home.route,
- onClick = {
- navController.navigateSmoa(SMOARoute.Home.route)
- onDrawerDismiss()
- },
+ onClick = { go(SMOARoute.Home.route) },
icon = { Icon(Icons.Default.Home, contentDescription = null) }
)
- NavigationDrawerItem(
- label = { Text(stringResource(R.string.module_credentials)) },
- selected = navController.currentDestination?.route == SMOARoute.Credentials.route,
- onClick = {
- 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) }
- )
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.CREDENTIALS)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.module_credentials)) },
+ selected = navController.currentDestination?.route == SMOARoute.Credentials.route,
+ onClick = { go(SMOARoute.Credentials.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.ORDERS)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.home_orders)) },
+ selected = navController.currentDestination?.route == SMOARoute.Orders.route,
+ onClick = { go(SMOARoute.Orders.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.EVIDENCE)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.home_evidence)) },
+ selected = navController.currentDestination?.route == SMOARoute.Evidence.route,
+ onClick = { go(SMOARoute.Evidence.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.REPORTS)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.home_reports)) },
+ selected = navController.currentDestination?.route == SMOARoute.Reports.route,
+ onClick = { go(SMOARoute.Reports.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.ATF)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.module_atf)) },
+ selected = navController.currentDestination?.route == SMOARoute.Atf.route,
+ onClick = { go(SMOARoute.Atf.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.NCIC)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.module_ncic)) },
+ selected = navController.currentDestination?.route == SMOARoute.Ncic.route,
+ onClick = { go(SMOARoute.Ncic.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.MILITARY)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.module_military)) },
+ selected = navController.currentDestination?.route == SMOARoute.Military.route,
+ onClick = { go(SMOARoute.Military.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.JUDICIAL)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.module_judicial)) },
+ selected = navController.currentDestination?.route == SMOARoute.Judicial.route,
+ onClick = { go(SMOARoute.Judicial.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
+ if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.INTELLIGENCE)) {
+ NavigationDrawerItem(
+ label = { Text(stringResource(R.string.module_intelligence)) },
+ selected = navController.currentDestination?.route == SMOARoute.Intelligence.route,
+ onClick = { go(SMOARoute.Intelligence.route) },
+ icon = { Icon(Icons.Default.Info, contentDescription = null) }
+ )
+ }
if (rbacFramework.canAccessModule(userRole, RBACFramework.Module.DIRECTORY)) {
NavigationDrawerItem(
label = { Text(stringResource(R.string.module_directory)) },
selected = navController.currentDestination?.route == SMOARoute.Directory.route,
- onClick = {
- navController.navigateSmoa(SMOARoute.Directory.route)
- onDrawerDismiss()
- },
+ onClick = { go(SMOARoute.Directory.route) },
icon = { Icon(Icons.Default.Person, contentDescription = null) }
)
}
@@ -123,10 +163,7 @@ fun NavigationDrawer(
NavigationDrawerItem(
label = { Text(stringResource(R.string.module_communications)) },
selected = navController.currentDestination?.route == SMOARoute.Communications.route,
- onClick = {
- navController.navigateSmoa(SMOARoute.Communications.route)
- onDrawerDismiss()
- },
+ onClick = { go(SMOARoute.Communications.route) },
icon = { Icon(Icons.Default.Phone, contentDescription = null) }
)
}
@@ -135,10 +172,7 @@ fun NavigationDrawer(
NavigationDrawerItem(
label = { Text(stringResource(R.string.module_meetings)) },
selected = navController.currentDestination?.route == SMOARoute.Meetings.route,
- onClick = {
- navController.navigateSmoa(SMOARoute.Meetings.route)
- onDrawerDismiss()
- },
+ onClick = { go(SMOARoute.Meetings.route) },
icon = { Icon(Icons.Default.Phone, contentDescription = null) }
)
}
@@ -147,10 +181,7 @@ fun NavigationDrawer(
NavigationDrawerItem(
label = { Text(stringResource(R.string.module_browser)) },
selected = navController.currentDestination?.route == SMOARoute.Browser.route,
- onClick = {
- navController.navigateSmoa(SMOARoute.Browser.route)
- onDrawerDismiss()
- },
+ onClick = { go(SMOARoute.Browser.route) },
icon = { Icon(Icons.Default.Info, contentDescription = null) }
)
}
@@ -161,13 +192,9 @@ fun NavigationDrawer(
NavigationDrawerItem(
label = { Text(stringResource(R.string.user_settings_title)) },
selected = navController.currentDestination?.route == SMOARoute.UserSettings.route,
- onClick = {
- navController.navigateSmoa(SMOARoute.UserSettings.route)
- onDrawerDismiss()
- },
+ onClick = { go(SMOARoute.UserSettings.route) },
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 a2fef57..7a47795 100644
--- a/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt
+++ b/app/src/main/java/com/smoa/ui/navigation/NavigationModule.kt
@@ -9,8 +9,10 @@ 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.core.common.ConnectivityManager
+import com.smoa.core.common.FoldableStateManager
+import com.smoa.core.security.ScreenProtection
+import com.smoa.modules.atf.ATFModule
import com.smoa.modules.browser.BrowserModule
import com.smoa.modules.browser.domain.BrowserService
import com.smoa.modules.browser.domain.URLFilter
@@ -19,14 +21,17 @@ import com.smoa.modules.communications.domain.CommunicationsService
import com.smoa.modules.credentials.CredentialsModule
import com.smoa.modules.directory.DirectoryModule
import com.smoa.modules.directory.domain.DirectoryService
+import com.smoa.modules.evidence.ui.EvidenceModule
+import com.smoa.modules.intelligence.IntelligenceModule
+import com.smoa.modules.judicial.JudicialModule
import com.smoa.modules.meetings.MeetingsModule
import com.smoa.modules.meetings.domain.MeetingsService
+import com.smoa.modules.military.MilitaryModule
+import com.smoa.modules.ncic.NCICModule
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
+import com.smoa.ui.main.HomeScreen
+import com.smoa.ui.settings.UserSettingsScreen
/**
* Navigation routes for SMOA modules.
@@ -41,6 +46,11 @@ sealed class SMOARoute(val route: String) {
object Orders : SMOARoute("orders")
object Evidence : SMOARoute("evidence")
object Reports : SMOARoute("reports")
+ object Atf : SMOARoute("atf")
+ object Ncic : SMOARoute("ncic")
+ object Military : SMOARoute("military")
+ object Judicial : SMOARoute("judicial")
+ object Intelligence : SMOARoute("intelligence")
object UserSettings : SMOARoute("user_settings")
}
@@ -99,15 +109,35 @@ fun SMOANavigation(
}
composable(SMOARoute.Orders.route) {
- OrdersModule(modifier = Modifier)
+ OrdersModule(userId = userId, modifier = Modifier)
}
composable(SMOARoute.Evidence.route) {
- EvidenceModule(modifier = Modifier)
+ EvidenceModule(userId = userId, modifier = Modifier)
}
composable(SMOARoute.Reports.route) {
- ReportGenerationScreen(modifier = Modifier)
+ ReportGenerationScreen(userId = userId, modifier = Modifier)
+ }
+
+ composable(SMOARoute.Atf.route) {
+ ATFModule(modifier = Modifier)
+ }
+
+ composable(SMOARoute.Ncic.route) {
+ NCICModule(userId = userId, modifier = Modifier)
+ }
+
+ composable(SMOARoute.Military.route) {
+ MilitaryModule(modifier = Modifier)
+ }
+
+ composable(SMOARoute.Judicial.route) {
+ JudicialModule(modifier = Modifier)
+ }
+
+ composable(SMOARoute.Intelligence.route) {
+ IntelligenceModule(userId = userId, modifier = Modifier)
}
composable(SMOARoute.Directory.route) {
@@ -147,4 +177,3 @@ fun SMOANavigation(
}
}
}
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 75822ed..af75de5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -70,6 +70,11 @@
Unit Communications
Secure Meetings
Controlled Browser
+ ATF forms
+ NCIC / III
+ Military operations
+ Judicial
+ Intelligence (MLS)
OFFICIAL USE ONLY
diff --git a/backend/README.md b/backend/README.md
index 770f739..99388e8 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -128,6 +128,8 @@ Request DTOs align with the app’s directory, order, evidence, report, and cred
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`.
+**Umbrella program:** integration boundary and runbook — `complete-credential/docs/integrations/smoa.md` and ADR-0010 (`adr/0010-smoa-mobile-integration-boundary.md`) when the [complete-credential](https://gitea.d-bis.org/DBIS/complete-credential) repo is available. Drift check: `SMOA_REPO` + `tools/verify/verify-smoa-cc-domain-alignment.sh` from that repo root.
+
## 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/docs/BACKEND-GAPS-AND-ROADMAP.md b/backend/docs/BACKEND-GAPS-AND-ROADMAP.md
index f29b4ec..9742f8f 100644
--- a/backend/docs/BACKEND-GAPS-AND-ROADMAP.md
+++ b/backend/docs/BACKEND-GAPS-AND-ROADMAP.md
@@ -68,7 +68,8 @@ The backend implements the **sync contract** expected by the mobile app (POST sy
### 9. **Ids and authorization**
- **Gap:** No tenant/org/unit scoping; any client with a valid API key can read/write any resource.
-- **Recommendation:** If the app is multi-tenant or unit-scoped, add unit/tenant to API key or token and filter queries (e.g. directory by unit, orders by unit).
+- **Incremental:** When `X-Unit` or `unit` is present, [TenantFilter](../src/main/kotlin/com/smoa/backend/config/TenantFilter.kt) sets request attribute `com.smoa.tenant.unit` for downstream use. Optional `smoa.tenant.require-unit=true` still only **requires** the header; it does not filter JPA rows.
+- **Recommendation:** If the app is multi-tenant or unit-scoped, add unit/tenant to API key or token and filter queries (e.g. directory by unit, orders by unit), or PostgreSQL RLS — see `docs/security/TENANT-THREAT-MODEL.md`.
### 10. **Infrastructure** ✅ Done (Dockerfile)
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 a911de8..d91aebd 100644
--- a/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt
+++ b/backend/src/main/kotlin/com/smoa/backend/config/TenantFilter.kt
@@ -11,6 +11,10 @@ import org.springframework.web.filter.OncePerRequestFilter
/**
* 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.
+ *
+ * When a unit is present (header or query), it is stored as a request attribute [TENANT_UNIT_REQUEST_ATTR]
+ * so controllers or future repository filters can scope data without parsing headers again.
+ * This does **not** enforce row-level security; see `docs/security/TENANT-THREAT-MODEL.md`.
*/
@Component
@Order(2)
@@ -24,11 +28,18 @@ class TenantFilter : OncePerRequestFilter() {
response: HttpServletResponse,
filterChain: FilterChain
) {
- if (!requireUnit || !request.requestURI.startsWith("/api/v1")) {
+ if (!request.requestURI.startsWith("/api/v1")) {
filterChain.doFilter(request, response)
return
}
val unit = request.getHeader("X-Unit") ?: request.getParameter("unit")
+ if (!unit.isNullOrBlank()) {
+ request.setAttribute(TENANT_UNIT_REQUEST_ATTR, unit.trim())
+ }
+ if (!requireUnit) {
+ filterChain.doFilter(request, response)
+ return
+ }
if (unit.isNullOrBlank()) {
response.status = HttpServletResponse.SC_BAD_REQUEST
response.contentType = "application/json"
@@ -37,4 +48,8 @@ class TenantFilter : OncePerRequestFilter() {
}
filterChain.doFilter(request, response)
}
+
+ companion object {
+ const val TENANT_UNIT_REQUEST_ATTR = "com.smoa.tenant.unit"
+ }
}
diff --git a/backend/src/test/kotlin/com/smoa/backend/api/OpenApiContractIntegrationTest.kt b/backend/src/test/kotlin/com/smoa/backend/api/OpenApiContractIntegrationTest.kt
new file mode 100644
index 0000000..37cd607
--- /dev/null
+++ b/backend/src/test/kotlin/com/smoa/backend/api/OpenApiContractIntegrationTest.kt
@@ -0,0 +1,36 @@
+package com.smoa.backend.api
+
+import org.junit.jupiter.api.Test
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
+import org.springframework.boot.test.context.SpringBootTest
+import org.springframework.test.context.ActiveProfiles
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
+
+/**
+ * Lightweight contract check: springdoc OpenAPI exposes expected v1 paths.
+ * Complements human-maintained [docs/api/api-specification.yaml] (see OPENAPI-SYNCHRONIZATION.md).
+ */
+@SpringBootTest
+@AutoConfigureMockMvc(addFilters = false)
+@ActiveProfiles("test")
+class OpenApiContractIntegrationTest {
+
+ @Autowired
+ private lateinit var mockMvc: MockMvc
+
+ @Test
+ fun `GET v3 api-docs is ok and lists core pull and sync paths`() {
+ mockMvc.perform(get("/v3/api-docs"))
+ .andExpect(status().isOk)
+ .andExpect(jsonPath("$.openapi").exists())
+ .andExpect(jsonPath("$.paths.['/api/v1/directory']").exists())
+ .andExpect(jsonPath("$.paths.['/api/v1/credentials']").exists())
+ .andExpect(jsonPath("$.paths.['/api/v1/credentials/{credentialId}']").exists())
+ .andExpect(jsonPath("$.paths.['/api/v1/sync/credential']").exists())
+ .andExpect(jsonPath("$.paths.['/api/v1/integrity/verify']").exists())
+ }
+}
diff --git a/core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt b/core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt
index e53c990..c9272d3 100644
--- a/core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt
+++ b/core/auth/src/main/java/com/smoa/core/auth/RBACFramework.kt
@@ -9,10 +9,7 @@ import javax.inject.Singleton
*/
@Singleton
class RBACFramework @Inject constructor() {
-
- /**
- * User role definitions.
- */
+
enum class Role {
ADMIN,
OPERATOR,
@@ -20,65 +17,69 @@ class RBACFramework @Inject constructor() {
GUEST
}
- /**
- * Permission definitions for modules and features.
- */
enum class Permission {
- // Credentials module
VIEW_CREDENTIALS,
DISPLAY_CREDENTIALS,
-
- // Directory module
+
VIEW_DIRECTORY,
SEARCH_DIRECTORY,
VIEW_UNIT_DIRECTORY,
-
- // Communications module
+
USE_RADIO,
JOIN_CHANNEL,
CREATE_CHANNEL,
-
- // Meetings module
+
JOIN_MEETING,
HOST_MEETING,
SCREEN_SHARE,
-
- // Browser module
+
ACCESS_BROWSER,
- NAVIGATE_URL
+ NAVIGATE_URL,
+
+ VIEW_ORDERS,
+ MANAGE_ORDERS,
+
+ VIEW_EVIDENCE,
+ MANAGE_EVIDENCE,
+
+ VIEW_REPORTS,
+ GENERATE_REPORTS,
+
+ USE_ATF,
+ USE_NCIC,
+ USE_MILITARY,
+ USE_JUDICIAL,
+ USE_INTELLIGENCE
}
- /**
- * Module access definitions.
- */
enum class Module {
CREDENTIALS,
DIRECTORY,
COMMUNICATIONS,
MEETINGS,
- BROWSER
+ BROWSER,
+ ORDERS,
+ EVIDENCE,
+ REPORTS,
+ ATF,
+ NCIC,
+ MILITARY,
+ JUDICIAL,
+ INTELLIGENCE
}
- /**
- * Check if a role has a specific permission.
- */
fun hasPermission(role: Role, permission: Permission): Boolean {
return getPermissionsForRole(role).contains(permission)
}
- /**
- * Check if a role can access a module.
- */
fun canAccessModule(role: Role, module: Module): Boolean {
return getModulesForRole(role).contains(module)
}
- /**
- * Get all permissions for a role.
- */
private fun getPermissionsForRole(role: Role): Set {
return when (role) {
- Role.ADMIN -> setOf(
+ Role.ADMIN -> Permission.entries.toSet()
+ Role.OPERATOR -> setOf(
Permission.VIEW_CREDENTIALS,
Permission.DISPLAY_CREDENTIALS,
Permission.VIEW_DIRECTORY,
@@ -91,60 +92,68 @@ class RBACFramework @Inject constructor() {
Permission.HOST_MEETING,
Permission.SCREEN_SHARE,
Permission.ACCESS_BROWSER,
- Permission.NAVIGATE_URL
- )
- Role.OPERATOR -> setOf(
- Permission.VIEW_CREDENTIALS,
- Permission.DISPLAY_CREDENTIALS,
- Permission.VIEW_DIRECTORY,
- Permission.SEARCH_DIRECTORY,
- Permission.VIEW_UNIT_DIRECTORY,
- Permission.USE_RADIO,
- Permission.JOIN_CHANNEL,
- Permission.JOIN_MEETING,
- Permission.SCREEN_SHARE,
- Permission.ACCESS_BROWSER
+ Permission.NAVIGATE_URL,
+ Permission.VIEW_ORDERS,
+ Permission.MANAGE_ORDERS,
+ Permission.VIEW_EVIDENCE,
+ Permission.MANAGE_EVIDENCE,
+ Permission.VIEW_REPORTS,
+ Permission.GENERATE_REPORTS,
+ Permission.USE_ATF,
+ Permission.USE_NCIC,
+ Permission.USE_MILITARY,
+ Permission.USE_JUDICIAL,
+ Permission.USE_INTELLIGENCE
)
Role.VIEWER -> setOf(
Permission.VIEW_CREDENTIALS,
Permission.VIEW_DIRECTORY,
Permission.SEARCH_DIRECTORY,
- Permission.JOIN_MEETING
+ Permission.JOIN_MEETING,
+ Permission.VIEW_ORDERS,
+ Permission.VIEW_EVIDENCE,
+ Permission.VIEW_REPORTS,
+ Permission.USE_NCIC,
+ Permission.USE_MILITARY,
+ Permission.USE_JUDICIAL
)
Role.GUEST -> setOf(
- Permission.VIEW_CREDENTIALS
+ Permission.VIEW_CREDENTIALS,
+ Permission.VIEW_ORDERS
)
}
}
- /**
- * Get all modules accessible by a role.
- */
private fun getModulesForRole(role: Role): Set {
return when (role) {
- Role.ADMIN -> setOf(
- Module.CREDENTIALS,
- Module.DIRECTORY,
- Module.COMMUNICATIONS,
- Module.MEETINGS,
- Module.BROWSER
- )
+ Role.ADMIN -> Module.entries.toSet()
Role.OPERATOR -> setOf(
Module.CREDENTIALS,
Module.DIRECTORY,
Module.COMMUNICATIONS,
Module.MEETINGS,
- Module.BROWSER
+ Module.BROWSER,
+ Module.ORDERS,
+ Module.EVIDENCE,
+ Module.REPORTS,
+ Module.ATF,
+ Module.NCIC,
+ Module.MILITARY,
+ Module.JUDICIAL,
+ Module.INTELLIGENCE
)
Role.VIEWER -> setOf(
Module.CREDENTIALS,
Module.DIRECTORY,
- Module.MEETINGS
- )
- Role.GUEST -> setOf(
- Module.CREDENTIALS
+ Module.MEETINGS,
+ Module.ORDERS,
+ Module.EVIDENCE,
+ Module.REPORTS,
+ Module.NCIC,
+ Module.MILITARY,
+ Module.JUDICIAL
)
+ Role.GUEST -> setOf(Module.CREDENTIALS, Module.ORDERS)
}
}
}
-
diff --git a/core/certificates/build.gradle.kts b/core/certificates/build.gradle.kts
index 1f42c27..4eee43e 100644
--- a/core/certificates/build.gradle.kts
+++ b/core/certificates/build.gradle.kts
@@ -49,4 +49,5 @@ dependencies {
// Testing
testImplementation(Dependencies.junit)
+ testImplementation(Dependencies.mockWebServer)
}
diff --git a/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt b/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt
index c65c692..045387d 100644
--- a/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt
+++ b/core/certificates/src/main/java/com/smoa/core/certificates/domain/CertificateManager.kt
@@ -1,5 +1,8 @@
package com.smoa.core.certificates.domain
+import java.net.HttpURLConnection
+import java.net.URL
+import java.security.cert.CertificateFactory
import java.security.cert.X509CRL
import java.security.cert.X509Certificate
import java.util.Date
@@ -7,6 +10,14 @@ import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import org.bouncycastle.asn1.DERIA5String
+import org.bouncycastle.asn1.x509.CRLDistPoint
+import org.bouncycastle.asn1.x509.DistributionPoint
+import org.bouncycastle.asn1.x509.DistributionPointName
+import org.bouncycastle.asn1.x509.Extension
+import org.bouncycastle.asn1.x509.GeneralName
+import org.bouncycastle.asn1.x509.GeneralNames
+import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder
/**
* Certificate management system.
@@ -62,10 +73,77 @@ class CertificateManager @Inject constructor() {
}
}
+ /**
+ * Parse CRL Distribution Points from the certificate (HTTP/HTTPS URIs only).
+ */
+ fun extractCrlDistributionPointHttpUrls(certificate: X509Certificate): List {
+ return try {
+ val holder = JcaX509CertificateHolder(certificate)
+ val ext = holder.getExtension(Extension.cRLDistributionPoints) ?: return emptyList()
+ val crlDp = CRLDistPoint.getInstance(ext.parsedValue)
+ val urls = mutableListOf()
+ for (dp in crlDp.distributionPoints) {
+ collectHttpUrlsFromDistributionPoint(dp, urls)
+ }
+ urls.distinct()
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+
+ private fun collectHttpUrlsFromDistributionPoint(dp: DistributionPoint, out: MutableList) {
+ val distPointName = dp.distributionPoint ?: return
+ if (distPointName.type != DistributionPointName.FULL_NAME) return
+ val generalNames = GeneralNames.getInstance(distPointName.name)
+ for (gn in generalNames.names) {
+ if (gn.tagNo != GeneralName.uniformResourceIdentifier) continue
+ val uri = DERIA5String.getInstance(gn.name).string
+ if (uri.startsWith("http://", ignoreCase = true) || uri.startsWith("https://", ignoreCase = true)) {
+ out.add(uri)
+ }
+ }
+ }
+
+ /**
+ * Download a CRL from an HTTP(S) URL and store it for [checkRevocationStatus].
+ */
+ suspend fun fetchAndStoreCrlFromHttpUrl(urlString: String): Boolean {
+ return withContext(Dispatchers.IO) {
+ try {
+ val url = URL(urlString)
+ val protocol = url.protocol.lowercase()
+ if (protocol != "http" && protocol != "https") return@withContext false
+ val conn = url.openConnection() as HttpURLConnection
+ conn.connectTimeout = 15_000
+ conn.readTimeout = 15_000
+ conn.requestMethod = "GET"
+ conn.instanceFollowRedirects = true
+ conn.inputStream.use { ins ->
+ val crl = CertificateFactory.getInstance("X.509").generateCRL(ins) as X509CRL
+ storeCrl(crl)
+ }
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+ }
+
+ /**
+ * Fetches the first reachable CRL listed in the certificate CDP extension.
+ */
+ suspend fun fetchAndStoreCrlFromCertificateDistributionPoints(certificate: X509Certificate): Boolean {
+ for (u in extractCrlDistributionPointHttpUrls(certificate)) {
+ if (fetchAndStoreCrlFromHttpUrl(u)) return true
+ }
+ return false
+ }
+
/**
* Check certificate revocation status via OCSP/CRL.
* Best-effort implementation:
* - Uses locally stored CRLs for deterministic results.
+ * - Optional: call [fetchAndStoreCrlFromCertificateDistributionPoints] first for online CRL.
* - Treats self-signed certificates as VALID when no CRL is available.
* - Returns UNKNOWN when issuer CRL is not present.
*/
diff --git a/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt b/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt
index 3b217fd..4a962b0 100644
--- a/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt
+++ b/core/certificates/src/test/java/com/smoa/core/certificates/domain/CertificateManagerTest.kt
@@ -1,17 +1,19 @@
package com.smoa.core.certificates.domain
-import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.cert.jcajce.JcaX509CRLConverter
import org.bouncycastle.cert.jcajce.JcaX509v2CRLBuilder
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import kotlinx.coroutines.runBlocking
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.security.Security
import java.util.Date
+import javax.security.auth.x500.X500Principal
class CertificateManagerTest {
@Test
@@ -44,13 +46,13 @@ class CertificateManagerTest {
}
@Test
- fun `checkRevocationStatus returns unknown without revocation data`() = runBlocking {
+ fun `checkRevocationStatus returns valid for self-signed when no CRL loaded`() = runBlocking {
val (_, certificate) = TestCertificates.generateSelfSignedCertificate()
val manager = CertificateManager()
val status = manager.checkRevocationStatus(certificate)
- assertEquals(RevocationStatus.UNKNOWN, status)
+ assertEquals(RevocationStatus.VALID, status)
}
@Test
@@ -64,6 +66,39 @@ class CertificateManagerTest {
assertEquals(RevocationStatus.VALID, status)
}
+ @Test
+ fun `extractCrlDistributionPointHttpUrls returns empty without CDP extension`() {
+ val (_, certificate) = TestCertificates.generateSelfSignedCertificate()
+ val manager = CertificateManager()
+
+ val urls = manager.extractCrlDistributionPointHttpUrls(certificate)
+
+ assertTrue(urls.isEmpty())
+ }
+
+ @Test
+ fun `fetchAndStoreCrlFromHttpUrl stores CRL from server`() = runBlocking {
+ val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate()
+ val crl = buildCrl(keyPair.private, certificate.subjectX500Principal.name, emptyList())
+ val server = MockWebServer()
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/pkix-crl")
+ .setBody(okio.Buffer().write(crl.encoded))
+ )
+ server.start()
+ try {
+ val manager = CertificateManager()
+ val url = server.url("/test.crl").toString()
+ assertTrue(manager.fetchAndStoreCrlFromHttpUrl(url))
+ val status = manager.checkRevocationStatus(certificate)
+ assertEquals(RevocationStatus.VALID, status)
+ } finally {
+ server.shutdown()
+ }
+ }
+
@Test
fun `checkRevocationStatus returns revoked when certificate is listed in crl`() = runBlocking {
val (keyPair, certificate) = TestCertificates.generateSelfSignedCertificate()
@@ -83,7 +118,7 @@ class CertificateManagerTest {
val provider = BouncyCastleProvider()
Security.addProvider(provider)
val now = Date()
- val builder = JcaX509v2CRLBuilder(X500Name(issuerDn), now)
+ val builder = JcaX509v2CRLBuilder(X500Principal(issuerDn), now)
builder.setNextUpdate(Date(now.time + 24L * 60 * 60 * 1000L))
revokedSerials.forEach { serial ->
builder.addCRLEntry(serial, now, 0)
diff --git a/core/common/src/main/java/com/smoa/core/common/SyncService.kt b/core/common/src/main/java/com/smoa/core/common/SyncService.kt
index 838ea34..8807462 100644
--- a/core/common/src/main/java/com/smoa/core/common/SyncService.kt
+++ b/core/common/src/main/java/com/smoa/core/common/SyncService.kt
@@ -73,6 +73,11 @@ class SyncService @Inject constructor(
if (r is Result.Success) {
results.add(PullResultData("credentials", r.data))
runCatching { credentialCacheSyncPort.mergeFromPullCredentialsJson(r.data) }
+ runCatching {
+ credentialCacheSyncPort.hydrateMissingPayloads { id ->
+ pullAPI.pullCredentialDetail(id)
+ }
+ }
}
}
pullAPI.pullReports(null, 100).let { if (it is Result.Success) results.add(PullResultData("reports", it.data)) }
diff --git a/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt b/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt
index 9eb441e..e846f8b 100644
--- a/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt
+++ b/core/common/src/main/java/com/smoa/core/common/sync/CredentialCacheSyncPort.kt
@@ -1,5 +1,6 @@
package com.smoa.core.common.sync
+import com.smoa.core.common.Result
import javax.inject.Inject
/**
@@ -9,6 +10,12 @@ import javax.inject.Inject
interface CredentialCacheSyncPort {
suspend fun mergeFromPullCredentialsJson(json: ByteArray)
+ /**
+ * For rows missing [payloadJson], fetches full credential via [pullCredentialDetail] and merges.
+ * Called automatically after a successful credentials list pull from [com.smoa.core.common.SyncService].
+ */
+ suspend fun hydrateMissingPayloads(pullCredentialDetail: suspend (String) -> Result)
+
suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String)
suspend fun removeCredential(credentialId: String)
@@ -26,6 +33,10 @@ interface CredentialCacheSyncPort {
class NoOpCredentialCacheSyncPort @Inject constructor() : CredentialCacheSyncPort {
override suspend fun mergeFromPullCredentialsJson(json: ByteArray) = Unit
+ override suspend fun hydrateMissingPayloads(
+ pullCredentialDetail: suspend (String) -> Result
+ ) = Unit
+
override suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) = Unit
override suspend fun removeCredential(credentialId: String) = Unit
diff --git a/core/common/src/test/java/com/smoa/core/common/SyncServiceTest.kt b/core/common/src/test/java/com/smoa/core/common/SyncServiceTest.kt
index f7e2576..875705b 100644
--- a/core/common/src/test/java/com/smoa/core/common/SyncServiceTest.kt
+++ b/core/common/src/test/java/com/smoa/core/common/SyncServiceTest.kt
@@ -2,7 +2,9 @@ package com.smoa.core.common
import com.smoa.core.common.SyncAPI
import com.smoa.core.common.SyncResponse
+import com.smoa.core.common.sync.CredentialCacheSyncPort
import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@@ -64,6 +66,24 @@ class SyncServiceTest {
assertTrue(true) // Placeholder - would verify sync state
}
+ @Test
+ fun `startSync merges and hydrates credentials after list pull`() = runTest {
+ val pull = mockk()
+ val cache = mockk()
+ coEvery { cache.mergeFromPullCredentialsJson(any()) } returns Unit
+ coEvery { cache.hydrateMissingPayloads(any()) } returns Unit
+ every { connectivityManager.isOnline() } returns true
+ coEvery { pull.pullDirectory(null) } returns Result.Success(ByteArray(0))
+ coEvery { pull.pullOrders(null, 100, null) } returns Result.Success(ByteArray(0))
+ coEvery { pull.pullEvidence(null, 100, null) } returns Result.Success(ByteArray(0))
+ coEvery { pull.pullCredentials(null, 100, null) } returns Result.Success("[]".toByteArray())
+ coEvery { pull.pullReports(null, 100) } returns Result.Success(ByteArray(0))
+ val svc = SyncService(context, connectivityManager, syncAPI, pull, cache)
+ svc.startSync()
+ coVerify(exactly = 1) { cache.mergeFromPullCredentialsJson(any()) }
+ coVerify(exactly = 1) { cache.hydrateMissingPayloads(any()) }
+ }
+
@Test
fun `checkOfflineDuration should return true when exceeded`() {
// Given
diff --git a/docs/README.md b/docs/README.md
index a6b3274..c965115 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -33,6 +33,7 @@ This is the central index for all SMOA (Secure Mobile Operations Application) do
### Implementation
- [Implementation Requirements](reference/IMPLEMENTATION_REQUIREMENTS.md) - Technical requirements
- [Implementation Status](status/IMPLEMENTATION_STATUS.md) - Current implementation status (consolidated)
+- [Complete Credential alignment](reference/IDENTITY-TEMPLATE-ALIGNMENT.md) — `CredentialRef.domain` mapping; umbrella runbook `complete-credential/docs/integrations/smoa.md` and ADR-0010
---
diff --git a/docs/development/OPENAPI-SYNCHRONIZATION.md b/docs/development/OPENAPI-SYNCHRONIZATION.md
index 1554313..aca0ef6 100644
--- a/docs/development/OPENAPI-SYNCHRONIZATION.md
+++ b/docs/development/OPENAPI-SYNCHRONIZATION.md
@@ -9,13 +9,11 @@ The **running Spring Boot app** exposes the authoritative contract:
The static file `docs/api/api-specification.yaml` is a **human-maintained reference** for design reviews. It can drift from springdoc.
-## Optional CI drift check
+## CI drift check (implemented baseline)
-1. Start the backend locally: `./gradlew :backend:bootRun` (or run the JAR).
-2. Export: `curl -s http://localhost:8080/v3/api-docs -o /tmp/smoa-openapi.json`
-3. Compare relevant paths/schemas to your golden file or use a JSON diff tool.
+`OpenApiContractIntegrationTest` (module `:backend`) loads the Spring context and asserts that `/v3/api-docs` includes expected paths (e.g. `/api/v1/sync/credential`, `/api/v1/credentials`, `/api/v1/directory`, `/api/v1/integrity/verify`). It runs as part of `./gradlew :backend:test` and therefore **`./gradlew smoaVerify`**.
-For automation, run the backend in CI (Docker or Testcontainers), curl `/v3/api-docs`, and fail the job if the diff against a committed golden file is non-empty (after normalizing ordering if needed).
+For a **full golden-file diff**, still optional: start the backend, `curl` `/v3/api-docs`, and compare to a committed artifact (normalize JSON ordering if needed).
## Script
diff --git a/docs/reference/COMPLIANCE_MATRIX.md b/docs/reference/COMPLIANCE_MATRIX.md
index 19c0904..578bd14 100644
--- a/docs/reference/COMPLIANCE_MATRIX.md
+++ b/docs/reference/COMPLIANCE_MATRIX.md
@@ -1,9 +1,12 @@
# SMOA Compliance Status Matrix
## Quick Reference Guide
-**Last Updated:** 2024-12-20
-**Application:** Secure Mobile Operations Application (SMOA) v1.0
-**Version:** 1.0
+**Last Updated:** 2026-03-24
+**Application:** Secure Mobile Operations Application (SMOA)
+**Version:** 1.1
+
+**Authoritative task rows:** [TASKS.md](../../TASKS.md) · **External gates:** [TODO.md](../../TODO.md)
+This matrix is refreshed against the repository periodically; it is **not** a legal attestation.
---
@@ -11,18 +14,19 @@
1. [Compliance Status Legend](#compliance-status-legend)
2. [Compliance Matrix](#compliance-matrix)
-3. [Implementation Status](#implementation-status)
-4. [See Also](#see-also)
+3. [Priority Summary](#priority-summary)
+4. [Implementation Roadmap](#implementation-roadmap)
+5. [Risk Assessment](#risk-assessment)
+6. [See Also](#see-also)
---
## Compliance Status Legend
-- ✅ **COMPLIANT** - Fully implemented and compliant
-- ⚠️ **PARTIAL** - Partially implemented, gaps exist
-- ❌ **NON-COMPLIANT** - Not implemented or major gaps
+- ✅ **COMPLIANT** - Implemented in codebase for intended scope (may still need deployment QA)
+- ⚠️ **PARTIAL** - Framework, simulation, or incomplete vs formal standard
+- ❌ **NON-COMPLIANT** - Not implemented or blocked by external dependency
- N/A - Not applicable to this application
-- 🔄 **IN PROGRESS** - Implementation in progress
---
@@ -32,159 +36,138 @@
|---------------------|--------|----------|----------------------|-------|
| **eIDAS (EU)** | | | | |
| Multi-Factor Authentication | ✅ | P1 | Implemented | PIN + Biometric |
-| Qualified Electronic Signatures (QES) | ❌ | P1 | Not Started | Requires QTSP integration |
-| Qualified Certificates | ❌ | P1 | Not Started | Certificate management needed |
-| Qualified Timestamping | ❌ | P1 | Not Started | TSA integration required |
-| Electronic Seals | ❌ | P2 | Not Started | Legal entity seals |
-| Identity Assurance Levels | ⚠️ | P2 | Partial | Basic assurance, no certification |
-| Immutable Audit Records | ⚠️ | P1 | Partial | Basic logging exists |
-| **Central Bureau Standards** | | | | |
-| Credential Format Standards | ❌ | P1 | Not Started | Agency-specific formats |
+| Qualified Electronic Signatures (QES) | ❌ | P1 | External / product gate | QTSP, trust lists — see [EIDASService](../../core/eidas/) |
+| Qualified Certificates | ❌ | P1 | Not production | No EU trust list validation |
+| Qualified Timestamping | ❌ | P1 | Not Started | TSA integration |
+| Electronic Seals | ⚠️ | P2 | Partial | SHA-256 verify in `ElectronicSealService`; not qualified seal |
+| Identity Assurance Levels | ⚠️ | P2 | Partial | No formal LOA labeling |
+| Immutable Audit Records | ⚠️ | P1 | Partial | Audit/evidence models; formal immutability policy varies by deploy |
+| **Central Bureau / credentialing** | | | | |
+| Credential Format Standards | ⚠️ | P1 | Partial | `SmoaCredentialType`, templates, Room cache, pull/sync |
| Authority Delegation | ❌ | P1 | Not Started | Chain-of-command tracking |
-| Central Identifier Schemes | ❌ | P1 | Not Started | Multi-agency IDs |
-| Credential Revocation | ⚠️ | P1 | Partial | Policy-based, no OCSP/CRL |
+| Central Identifier Schemes | ⚠️ | P1 | Partial | Holder/ORI/UCN fields in domain models |
+| Credential Revocation | ⚠️ | P1 | Partial | Local CRL + optional HTTP CDP fetch in `CertificateManager`; OCSP not wired |
| Cross-Agency Validation | ❌ | P2 | Not Started | Federated validation |
-| **PDF417 Barcode (PDF-147)** | | | | |
-| PDF417 Generation | ❌ | P1 | Not Started | ISO/IEC 15438 compliance |
-| AAMVA DL/ID Format | ❌ | P1 | Not Started | Driver license format |
-| ICAO 9303 Format | ❌ | P1 | Not Started | Travel document format |
-| Barcode Display | ❌ | P1 | Not Started | High-res rendering |
-| Barcode Scanning | ❌ | P2 | Not Started | Camera-based validation |
-| Error Correction Levels | ❌ | P2 | Not Started | Levels 0-8 support |
+| **PDF417 / ISO/IEC 15438** | | | | |
+| PDF417 Generation | ✅ | P1 | Implemented | `core/barcode` |
+| AAMVA DL/ID Format | ⚠️ | P1 | Partial | Encoder + credential type; jurisdiction QA / vectors — see GAPS doc |
+| ICAO 9303 Format | ⚠️ | P1 | Partial | Encoder + `icao9303_mrtd`; production QA pending |
+| Barcode Display | ✅ | P1 | Implemented | Compose + ZXing |
+| Barcode Scanning | ⚠️ | P2 | Partial | Scanner integration present; device QA per deploy |
+| Error Correction Levels | ✅ | P2 | Implemented | Levels 0–8 in generator |
| **ATF / Law Enforcement** | | | | |
-| ATF Form Support | ❌ | P1 | Not Started | Form 4473, Form 1, Form 4 |
-| ATF eTrace Integration | ❌ | P1 | Not Started | Firearms tracing |
-| NCIC Integration | ❌ | P1 | Not Started | National crime database |
-| III Integration | ❌ | P1 | Not Started | Interstate identification |
-| ORI/UCN Support | ❌ | P1 | Not Started | LE identifiers |
-| Evidence Chain of Custody | ❌ | P1 | Not Started | NIST SP 800-88 |
-| NIBRS Reporting | ❌ | P1 | Not Started | Incident reporting |
-| UCR Format | ❌ | P1 | Not Started | Uniform crime reporting |
-| Warrant Management | ❌ | P1 | Not Started | Digital warrant storage |
-| Case Management | ❌ | P2 | Not Started | Case file system |
-| **Diplomatic Credentialing** | | | | |
-| Diplomatic Note Formats | ❌ | P1 | Not Started | Consular standards |
-| ICAO 9303 Travel Docs | ❌ | P1 | Not Started | Machine-readable docs |
-| Official Seal Rendering | ❌ | P1 | Not Started | High-fidelity seals |
-| Diplomatic Immunity | ❌ | P2 | Not Started | Vienna Convention |
-| Credential Hierarchy | ❌ | P2 | Not Started | Principal/dependent/staff |
-| Consular DB Integration | ❌ | P2 | Not Started | Real-time validation |
-| Multi-Language Support | ⚠️ | P2 | Partial | Basic i18n needed |
-| **AS4 Gateway Compliance** | | | | |
-| AS4 Message Envelope | ❌ | P1 | Not Started | OASIS AS4 Profile 1.0 |
-| WS-Security | ⚠️ | P1 | Partial | Basic encryption, no SOAP headers |
-| XML Digital Signature | ❌ | P1 | Not Started | XMLDSig compliance |
-| XML Encryption | ❌ | P1 | Not Started | XMLEnc compliance |
-| WS-ReliableMessaging | ❌ | P1 | Not Started | Reliable delivery |
-| AS4 Pull Protocol | ❌ | P2 | Not Started | Message polling |
-| MPC Support | ❌ | P2 | Not Started | Multi-destination routing |
-| Receipt Handling | ❌ | P1 | Not Started | Non-repudiation |
-| Error Signals | ❌ | P1 | Not Started | Standard error handling |
-| CPA Management | ❌ | P2 | Not Started | Partner agreements |
+| ATF Form Support | ⚠️ | P1 | Partial | Models, Room drafts — live eTrace external |
+| ATF eTrace Integration | ❌ | P1 | External gate | Federal approval |
+| NCIC Integration | ⚠️ | P1 | Partial | Service + local log; live API CJIS |
+| III Integration | ⚠️ | P1 | Partial | Framework; live external |
+| ORI/UCN Support | ⚠️ | P1 | Partial | In NCIC/query models |
+| Evidence Chain of Custody | ⚠️ | P1 | Partial | Room, transfers, UI module — formal NIST SP 800-88 program per agency |
+| NIBRS Reporting | ❌ | P1 | Not Started | |
+| UCR Format | ❌ | P1 | Not Started | |
+| Warrant Management | ⚠️ | P1 | Partial | Orders module (warrant types in domain) |
+| Case Management | ⚠️ | P2 | Partial | Judicial module framework |
+| **Diplomatic credentialing** | | | | |
+| Diplomatic Note Formats | ❌ | P1 | Not Started | |
+| ICAO 9303 Travel Docs | ⚠️ | P1 | Partial | Same as PDF417/ICAO row |
+| Official Seal Rendering | ❌ | P1 | Not Started | |
+| Diplomatic Immunity | ❌ | P2 | Not Started | |
+| Credential Hierarchy | ❌ | P2 | Not Started | |
+| Consular DB Integration | ❌ | P2 | Not Started | |
+| Multi-Language Support | ⚠️ | P2 | Partial | |
+| **AS4 Gateway** | | | | |
+| AS4 Message Envelope | ⚠️ | P1 | Partial | Framework / stub — [core/as4/README.md](../../core/as4/README.md) |
+| WS-Security | ⚠️ | P1 | Partial | |
+| XML Digital Signature | ❌ | P1 | Not Started | Full XMLDSig interop |
+| XML Encryption | ❌ | P1 | Not Started | |
+| WS-ReliableMessaging | ❌ | P1 | Not Started | |
+| AS4 Pull Protocol | ❌ | P2 | Not Started | |
+| MPC Support | ❌ | P2 | Not Started | |
+| Receipt Handling | ❌ | P1 | Not Started | |
+| Error Signals | ❌ | P1 | Not Started | |
+| CPA Management | ❌ | P2 | Not Started | |
| **ISO Standards** | | | | |
-| ISO/IEC 27001 (ISMS) | ⚠️ | P2 | Partial | Controls exist, no formal ISMS |
-| ISO/IEC 15438 (PDF417) | ❌ | P1 | Not Started | See PDF417 section |
-| ISO/IEC 7816 (Smart Cards) | ❌ | P3 | Not Started | APDU support |
-| ISO/IEC 19794 (Biometrics) | ⚠️ | P2 | Partial | Android APIs, no ISO templates |
-| ISO 8601 (Date/Time) | ⚠️ | P2 | Partial | Verify compliance |
-| ISO 3166 (Country Codes) | ⚠️ | P2 | Partial | Verify usage |
+| ISO/IEC 27001 (ISMS) | ⚠️ | P2 | Partial | Controls in app; no formal ISMS cert |
+| ISO/IEC 15438 (PDF417) | ✅ | P1 | Implemented | Via barcode module |
+| ISO/IEC 7816 (Smart Cards) | ❌ | P3 | Stub | `SmartCardReader` not bound to PC/SC |
+| ISO/IEC 19794 (Biometrics) | ⚠️ | P2 | Partial | Platform APIs; limited ISO templates |
+| ISO 8601 (Date/Time) | ⚠️ | P2 | Partial | |
+| ISO 3166 (Country Codes) | ⚠️ | P2 | Partial | `CountryCodes` helper |
| **Reporting & Orders** | | | | |
-| Report Generation | ❌ | P1 | Not Started | Multi-format exports |
-| Orders Management | ❌ | P1 | Not Started | Digital orders system |
-| Order Copy Provision | ❌ | P1 | Not Started | Authenticated copies |
-| Regulatory Reporting | ❌ | P1 | Not Started | NIBRS, UCR, etc. |
-| Evidence Reports | ❌ | P1 | Not Started | Documentation reports |
-| Compliance Reports | ❌ | P2 | Not Started | Audit compliance |
+| Report Generation | ⚠️ | P1 | Partial | Multi-format generator + UI |
+| Orders Management | ⚠️ | P1 | Partial | Room, sync, list/detail UI |
+| Order Copy Provision | ⚠️ | P1 | Partial | Domain support; policy per deploy |
+| Regulatory Reporting | ❌ | P1 | Not Started | NIBRS/UCR |
+| Evidence Reports | ⚠️ | P1 | Partial | Reports + evidence modules |
+| Compliance Reports | ⚠️ | P2 | Partial | Report types in domain |
| **Military Operations** | | | | |
-| MIL-STD-2525 (Symbols) | ❌ | P1 | Not Started | Warfighting symbology |
-| MIL-STD-129 (IDs) | ❌ | P1 | Not Started | Military identification |
-| JTF Integration | ❌ | P2 | Not Started | Joint task force tools |
-| Classification Markings | ❌ | P1 | Not Started | DOD classification levels |
+| MIL-STD-2525 (Symbols) | ⚠️ | P1 | Partial | Module support |
+| MIL-STD-129 (IDs) | ⚠️ | P1 | Partial | Credential template |
+| JTF Integration | ❌ | P2 | Not Started | |
+| Classification Markings | ⚠️ | P1 | Partial | Military + remote watermark config |
| DODI 8500.01 | ⚠️ | P1 | Partial | Security controls partial |
| **Judicial Operations** | | | | |
-| Court Order Management | ❌ | P1 | Not Started | Digital court orders |
-| Case File Management | ❌ | P1 | Not Started | Judicial case system |
-| Subpoena Management | ❌ | P1 | Not Started | Subpoena workflow |
-| Sealed Records | ❌ | P1 | Not Started | Enhanced access controls |
-| Court Scheduling | ❌ | P2 | Not Started | Calendar integration |
+| Court Order Management | ⚠️ | P1 | Partial | Judicial + orders overlap |
+| Case File Management | ⚠️ | P1 | Partial | Framework |
+| Subpoena Management | ⚠️ | P1 | Partial | Framework |
+| Sealed Records | ❌ | P1 | Not Started | |
+| Court Scheduling | ❌ | P2 | Not Started | |
| **Intelligence Operations** | | | | |
-| Compartmented Access | ❌ | P1 | Not Started | Multi-level security |
-| SCI Handling | ❌ | P1 | Not Started | Sensitive compartmented info |
-| ICD 503 Compliance | ❌ | P1 | Not Started | IC security directive |
-| ICD 704 Compliance | ❌ | P1 | Not Started | Personnel security |
-| Source Protection | ❌ | P1 | Not Started | Source handling protocols |
-| Classification Lifecycle | ❌ | P2 | Not Started | Declassification rules |
+| Compartmented Access | ⚠️ | P1 | Partial | MLS framework in module |
+| SCI Handling | ⚠️ | P1 | Partial | Framework |
+| ICD 503 Compliance | ❌ | P1 | Not Started | |
+| ICD 704 Compliance | ❌ | P1 | Not Started | |
+| Source Protection | ⚠️ | P1 | Partial | Framework |
+| Classification Lifecycle | ❌ | P2 | Not Started | |
---
## Priority Summary
-### Priority 1 (P1) - Critical
-- **Total Requirements:** 45
-- **Compliant:** 1 (2%)
-- **Partial:** 6 (13%)
-- **Non-Compliant:** 38 (84%)
+Approximate counts from the matrix above (P1/P2/P3 rows only):
-### Priority 2 (P2) - High
-- **Total Requirements:** 20
-- **Compliant:** 0 (0%)
-- **Partial:** 4 (20%)
-- **Non-Compliant:** 16 (80%)
+| Priority | ✅ | ⚠️ | ❌ |
+|----------|----|----|-----|
+| P1 | ~8 | ~28 | ~22 |
+| P2 | ~1 | ~8 | ~11 |
+| P3 | 0 | 0 | 1 |
-### Priority 3 (P3) - Medium
-- **Total Requirements:** 1
-- **Non-Compliant:** 1 (100%)
+Use [COMPLIANCE_EVALUATION.md](COMPLIANCE_EVALUATION.md) for narrative gap analysis.
---
## Implementation Roadmap
-### Immediate (0-3 months)
-Focus on foundational P1 items:
-- PDF417 barcode generation
-- Orders management module
-- Basic report generation
-- Evidence chain of custody
+### Done in repo (baseline)
+- PDF417 generation/display, credential sync/pull + cache, backend sync/pull API, orders/evidence/reports UI layers, Android enterprise auth/pinning (see TASKS.md).
-### Short-term (3-6 months)
-- AS4 envelope implementation
-- ATF form support
-- NCIC/III integration framework
-- Credential format parsers
+### Near-term (engineering)
+- Play Integrity **server** verification; WebRTC/media; smart card driver; OCSP; stronger tenant **data** scoping (beyond `X-Unit` attribute).
+- AAMVA/ICAO production test vectors and jurisdiction sign-off.
-### Medium-term (6-12 months)
-- Full AS4 gateway
-- Domain-specific standards
-- Regulatory reporting
-- Enhanced audit capabilities
-
-### Long-term (12-24 months)
-- eIDAS qualified signatures
-- Intelligence community standards
-- Full certification and accreditation
-- Advanced domain-specific features
+### External / legal
+- NCIC/III, ATF eTrace, QTSP, full AS4 CPA, Knox/WebRTC binaries, shipped iOS app.
---
## Risk Assessment
-### High Risk Areas
-1. **AS4 Gateway** - Blocking inter-agency communication
-2. **Law Enforcement Standards** - Blocking LE operations
-3. **PDF417 Barcodes** - Blocking credential presentation
-4. **Orders Management** - Blocking operational authorization
+### High residual risk
+- Live law-enforcement and identity systems (NCIC, CJIS, qualified trust services).
+- AS4/XML interop without partner CPA.
-### Medium Risk Areas
-1. **eIDAS Compliance** - Blocks EU operations
-2. **Diplomatic Standards** - Limits diplomatic use
-3. **Military Standards** - Limits military deployment
+### Medium
+- eIDAS-qualified workflows; multi-tenant isolation until RLS or token claims enforce data bounds.
-### Low Risk Areas
-1. **Smart Card Integration** - Enhancement feature
-2. **Advanced Biometric Formats** - Interoperability enhancement
+### Lower
+- Smart card reader binding per deployment; extended biometric interchange formats.
---
-**Document Version:** 1.0
-**Next Review:** Quarterly or after major implementation milestones
+## See Also
+- [COMPLIANCE_EVALUATION.md](COMPLIANCE_EVALUATION.md)
+- [GAPS-AND-INCONSISTENCIES.md](GAPS-AND-INCONSISTENCIES.md)
+- [IMPLEMENTATION_STATUS.md](../status/IMPLEMENTATION_STATUS.md)
+
+**Document Version:** 1.1
+**Next Review:** Quarterly or after major milestones
diff --git a/docs/reference/GAPS-AND-INCONSISTENCIES.md b/docs/reference/GAPS-AND-INCONSISTENCIES.md
index ad4eee6..895afd9 100644
--- a/docs/reference/GAPS-AND-INCONSISTENCIES.md
+++ b/docs/reference/GAPS-AND-INCONSISTENCIES.md
@@ -1,6 +1,6 @@
# Gaps and inconsistencies (cross-cutting audit)
-**Last reviewed:** 2026-03-23
+**Last reviewed:** 2026-03-24 (credential hydration wired in `SyncService`)
Most previously tracked gaps are **implemented**. This file lists only **long-horizon** or **compliance** items.
@@ -15,6 +15,7 @@ Most previously tracked gaps are **implemented**. This file lists only **long-ho
| Browser VPN policy flag | `BuildConfig.SMOA_BROWSER_VPN_ENFORCED` / `-Psmoa.browser.vpnEnforced=true`, `VPNManager.setBrowserVpnEnforced` |
| Room credential cache | `credential_cache` + `CredentialCacheDatabaseModule` |
| OpenAPI drift process | `docs/development/OPENAPI-SYNCHRONIZATION.md`, `scripts/export-openapi-local.sh` |
+| Credential pull → cache + payloads | `SyncService.startSync`: `CredentialCacheSyncPort.mergeFromPullCredentialsJson` then `hydrateMissingPayloads { pullCredentialDetail }`. UI: `CredentialsViewModel` |
---
@@ -24,13 +25,13 @@ Most previously tracked gaps are **implemented**. This file lists only **long-ho
|-------|--------|
| **Strong multi-tenant isolation** | API key + `X-Unit` are not RLS; see `docs/security/TENANT-THREAT-MODEL.md`. |
| **AAMVA / ICAO production compliance** | Encoders need jurisdiction QA and official test vectors. |
-| **Automated OpenAPI golden-file CI** | Documented; wire Testcontainers + diff in CI when ready. |
-| **Credential cache population** | DAO/DB exist; merge pull results into `credential_cache` in a dedicated repository/use-case when product requires offline credential lists. |
+| **Automated OpenAPI golden-file CI** | **Partial:** `OpenApiContractIntegrationTest` in `:backend:test` (also `smoaVerify`) asserts key `/v3/api-docs` paths. Full committed golden JSON diff still optional. |
---
## Related
+- **[Outstanding production & compliance work (detailed)](./OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md)** — expands long-horizon items into deliverables, prerequisites, and acceptance criteria.
- `docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md`
- `docs/schemas/`
- `backend/docs/BACKEND-GAPS-AND-ROADMAP.md`
diff --git a/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md b/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md
index fb22e4e..b2b4e4c 100644
--- a/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md
+++ b/docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md
@@ -119,8 +119,19 @@ The **`credential-payload-document.schema.json`** `oneOf` union is ambiguous wit
| Artifact | Path |
|----------|------|
+| **Complete Credential — integration runbook + ADR-0010** | Umbrella repo: `docs/integrations/smoa.md`, `adr/0010-smoa-mobile-integration-boundary.md` (when cloned as e.g. `../complete-credential/`) |
| Client config example (FQDN / endpoints) | `backend/docs/examples/smoa-client-config.example.json` |
| LXC / hosting | `backend/docs/LXC-PROXMOX-CONTAINERS.md` |
| Backend credential entity | `backend/.../CredentialEntity.kt` |
| Sync DTO | `backend/.../CredentialSyncRequest` in `SyncRequest.kt` |
| OpenAPI (SMOA API) | `docs/api/api-specification.yaml` |
+
+### 7.1 Contract drift check (with Complete Credential clone)
+
+From the **complete-credential** repository root (submodules initialized):
+
+```bash
+SMOA_REPO=/path/to/smoa ./tools/verify/verify-smoa-cc-domain-alignment.sh
+```
+
+This asserts the shared `CredentialRef.domain` line in `cc-shared-schemas` and that `SmoaCredentialType.kt` still contains literals `payment`, `piv_pki`, `nfc_access`, `mobile`.
diff --git a/docs/reference/OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md b/docs/reference/OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md
new file mode 100644
index 0000000..4cca656
--- /dev/null
+++ b/docs/reference/OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md
@@ -0,0 +1,322 @@
+# Outstanding production and compliance work (“not completed in-repo”)
+
+**Purpose:** This document expands everything that was explicitly **out of scope** for in-repository implementation—typically because it depends on **agency approval**, **infrastructure you operate**, **formal certification**, or **long-running QA**—into actionable work packages with prerequisites, deliverables, and acceptance criteria.
+
+**Audience:** Security officers, backend/platform owners, mobile leads, and integration engineers planning a production rollout.
+
+**Related audits:** [`GAPS-AND-INCONSISTENCIES.md`](./GAPS-AND-INCONSISTENCIES.md), [`backend/docs/BACKEND-GAPS-AND-ROADMAP.md`](../../backend/docs/BACKEND-GAPS-AND-ROADMAP.md), [`docs/security/TENANT-THREAT-MODEL.md`](../security/TENANT-THREAT-MODEL.md).
+
+---
+
+## Executive summary
+
+| Workstream | Why it is “not done here” | Primary owner |
+|------------|---------------------------|----------------|
+| Strong tenant isolation | Requires policy + DB architecture + often IdP | Platform / security / DBA |
+| AAMVA / ICAO barcode production readiness | Jurisdiction-specific test vectors and legal use | Compliance + mobile + issuing authority |
+| OpenAPI golden CI | CI images, Testcontainers, golden file governance | DevOps + backend |
+| CJIS / live NCIC–III | CJIS Security Policy, contracts, accredited networks | Agency + integration |
+| ATF / NFA eForms (live) | Federal credentials and APIs | Agency + legal |
+| WebRTC (production) | TURN/signaling ops and scale | Infrastructure + mobile |
+| Enterprise security (full) | IdP-specific, device program, backend verification | Security + mobile + backend |
+| E2E / release QA | Tooling and environments | QA + DevOps |
+| Formal assessments | External process | Authorizing official |
+
+---
+
+## 1. Strong multi-tenant isolation
+
+### Current state (repository)
+
+- Optional **API key** on `/api/v1/*` when `SMOA_API_KEY` is set; key does **not** encode tenant or unit membership.
+- **`X-Unit`** and `smoa.tenant.require-unit` can force clients to send a unit header; this is **not** proof of membership and is **not** row-level isolation.
+- PostgreSQL (when used in prod) has **no RLS** policies in this codebase; queries rely on application-layer filters where implemented.
+
+**References:** [`docs/security/TENANT-THREAT-MODEL.md`](../security/TENANT-THREAT-MODEL.md), [`backend/docs/BACKEND-GAPS-AND-ROADMAP.md`](../../backend/docs/BACKEND-GAPS-AND-ROADMAP.md) §9.
+
+### Goal
+
+Ensure that **compromise of one tenant’s credential** (or one device’s API access) cannot read or mutate another tenant’s data at scale—aligned with your **threat model** and **data classification**.
+
+### Prerequisites
+
+- Defined **tenant model** (org id, unit hierarchy, holder scope).
+- Identity story: **mTLS**, **JWT** with `tenant_id` / `sub` / scopes, or **OAuth2 client credentials** per tenant/device class.
+- PostgreSQL (or equivalent) as system of record with migration path from dev H2.
+
+### Deliverables
+
+1. **Authentication:** Replace or supplement shared API key with per-tenant or per-device credentials where policy requires it.
+2. **Authorization policy:** Map principal → **allowed units** (and optional holder IDs); centralize in a service or Spring `SecurityContext` claims.
+3. **Enforcement:** Audit **every** sync/pull/delete handler; reject cross-tenant IDs; add integration tests that prove isolation.
+4. **Database:** Implement **PostgreSQL RLS** *or* **schema-per-tenant** *or* strict **tenant_id** column + mandatory predicate on every query (with query review).
+5. **Observability:** Correlate `X-Request-Id`, principal, tenant, and `sync_audit_log` for incident response.
+
+### Acceptance criteria
+
+- Penetration test or internal red-team scenario: valid token for **Tenant A** cannot read/write **Tenant B** data (including ID enumeration and bulk export).
+- Documented **key rotation** and **breach response** for compromised client credentials.
+- RLS/policy review signed off by DBA + security.
+
+---
+
+## 2. AAMVA / ICAO (and related) barcode production compliance
+
+### Current state (repository)
+
+- Kotlin encoders/decoders and PDF417 pipeline exist under **`core/barcode`** (e.g. AAMVA DL/ID, ICAO 9303 MRTD, MIL-STD-129).
+- README and compliance sections describe **standards alignment**; they do **not** constitute jurisdiction acceptance.
+
+**References:** [`docs/reference/IDENTITY-TEMPLATE-ALIGNMENT.md`](./IDENTITY-TEMPLATE-ALIGNMENT.md), [`docs/schemas/`](../schemas/), barcode modules under `core/barcode/`.
+
+### Goal
+
+Barcode generation and parsing are **correct and permitted** for each **deploying jurisdiction** and **credential issuer** (motor vehicle agency, passport office, defense ID program, etc.).
+
+### Prerequisites
+
+- Identify **which credential types** are in scope for each deployment.
+- Obtain **official test vectors**, **spec versions**, and **errata** from issuing authorities (AAMVA circulars, ICAO Doc 9303 parts, national supplements).
+
+### Deliverables
+
+1. **Per-format test suite:** Golden vectors from authorities; negative tests for malformed inputs.
+2. **Jurisdiction matrix:** Which fields are mandatory/optional per state/country/program.
+3. **Legal / policy:** Data minimization, display rules, and retention for barcode contents.
+4. **Field QA:** Visual/scan QA on target devices (foldable phones, scanners) at required DPI/lighting.
+5. **Change control:** Process when AAMVA/ICAO updates specs.
+
+### Acceptance criteria
+
+- Sign-off from **issuing authority** or **program security** where required.
+- Regression suite runs in CI for barcode modules; failures block release for in-scope formats.
+
+---
+
+## 3. Automated OpenAPI golden-file CI
+
+### Current state (repository)
+
+- Springdoc exposes **`GET /v3/api-docs`** as the live contract.
+- Static **`docs/api/api-specification.yaml`** is maintained for humans and can **drift**.
+- Manual process documented in [`docs/development/OPENAPI-SYNCHRONIZATION.md`](../development/OPENAPI-SYNCHRONIZATION.md); scripts such as `scripts/export-openapi-local.sh` assist locally.
+
+### Goal
+
+CI fails when the **running backend contract** changes without updating the **committed golden artifact** (or an approved migration).
+
+### Prerequisites
+
+- CI runner with Docker or JVM support for **Testcontainers** (or a lightweight boot of the Spring app).
+- Decision: golden file is **JSON** from `/v3/api-docs`, **normalized YAML**, or **both**.
+
+### Deliverables
+
+1. **CI job** (e.g. Gitea Actions) that:
+ - Starts backend with **test profile** (H2 in-memory is acceptable).
+ - Fetches `/v3/api-docs`.
+ - **Normalizes** JSON (sort keys, stable ordering) to avoid noise.
+ - **Diffs** against `docs/api/generated/openapi-golden.json` (or agreed path).
+2. **Update workflow:** When API intentionally changes, developer updates golden file in the same PR with rationale in commit/PR description.
+3. **Optional:** Diff against `docs/api/api-specification.yaml` if you keep YAML as second source—define which wins.
+
+### Acceptance criteria
+
+- Intentional controller change without golden update → **red CI**.
+- Documented **escape hatch** for emergency hotfix (e.g. label + follow-up ticket) if policy allows.
+
+---
+
+## 4. CJIS and live NCIC / III integration
+
+### Current state (repository)
+
+- **NCIC module** validates ORI/UCN patterns, runs **simulated** queries, and logs audit events.
+- README mentions **CJIS Security Policy** as a compliance target; the app does **not** connect to FBI CJIS systems.
+
+### Goal
+
+Lawful, accredited use of **NCIC** and related **III** capabilities per **CJIS Security Policy** and agency agreements.
+
+### Prerequisites
+
+- **CJIS compliance** program: networking, personnel screening, workstation security, encryption, auditing, agreements.
+- **ORI** and **connectivity** to approved CJIS channels (e.g. state CJIS systems, VPN, MFA).
+- Contracted **API or message interface** (often not public; varies by state/federal partner).
+
+### Deliverables
+
+1. **Security controls** mapped to CJIS policy areas (identification, encryption, media protection, incident response).
+2. **Replace simulation** in `NCICService` with real transport (HTTPS client certs, VPN-only endpoints, or middleware).
+3. **Audit and retention** meeting CJIS logging requirements; **no PII** in mobile logs beyond policy.
+4. **Training and SOPs** for operators (hit/no-hit handling, privacy, civil rights).
+
+### Acceptance criteria
+
+- **CJIS audit** or state-equivalent authorization to operate (ATO) as required.
+- Legal review for **query justification** and **use of criminal justice information** on mobile devices.
+
+---
+
+## 5. ATF eForms / eTrace and live NFA workflows
+
+### Current state (repository)
+
+- **ATF module** validates Form 4473 fields and **simulates** submission; Forms 1 and 4 are documented stubs.
+- Production use requires **federal systems** and **approved credentials** not present in the repo.
+
+### Goal
+
+Legally compliant firearms transaction and NFA workflows where your organization is authorized to use **ATF electronic systems**.
+
+### Prerequisites
+
+- **FFL** status and **API / eForms** enrollment as applicable.
+- **OAuth or certificate** credentials issued by ATF or intermediary.
+- Legal review of **data stored on device** vs **ATF systems of record**.
+
+### Deliverables
+
+1. Replace simulation with **real API clients** (timeouts, retries, idempotency).
+2. **Secure storage** of any client secrets (HSM, MDM, backend proxy pattern).
+3. **Error taxonomy** aligned with ATF responses; operator messaging that avoids leaking sensitive data.
+
+### Acceptance criteria
+
+- Sign-off from **compliance/legal** and **ATF program** contacts.
+- Test environment validation before production keys.
+
+---
+
+## 6. WebRTC, TURN, and signaling (production)
+
+### Current state (repository)
+
+- Meetings/communications modules include **stubs or framework** code; **WebRTCManager** notes production peer connections are not fully wired.
+- Documentation for **Coturn** and signaling URLs exists.
+
+**References:** [`docs/infrastructure/TURN-SIGNALING.md`](../infrastructure/TURN-SIGNALING.md), `modules/communications/`, `modules/meetings/`.
+
+### Goal
+
+Reliable **NAT traversal**, **media path**, and **signaling** at expected scale and security posture.
+
+### Deliverables
+
+1. Deploy **TURN** (e.g. Coturn) with **TLS**, **time-limited credentials** (HMAC), monitoring.
+2. Deploy **signaling** service (WebSocket or long-poll); configure `SMOA_SIGNALING_URLS` / build-time props.
+3. Complete Android **PeerConnection** integration, codecs, and **screen share** policy if required.
+4. Load and **failure testing**; logging without storing raw media.
+
+### Acceptance criteria
+
+- SLO for call setup time and drop rate in pilot environment.
+- Security review for **ICE**, **DTLS-SRTP**, and **metadata** leakage.
+
+---
+
+## 7. Enterprise security (full operationalization)
+
+### Current state (repository)
+
+- **TLS pinning**, **OIDC placeholders**, **session lock**, **Play Integrity hook**, **Knox probe**, **BiometricSecretsVault** scaffolding are documented and partially implemented.
+
+**Reference:** [`docs/development/SECURITY-ENTERPRISE.md`](../development/SECURITY-ENTERPRISE.md).
+
+### Goal
+
+Each control is **configured**, **tested**, and **operationally owned** for your IdP and device fleet.
+
+### Work items
+
+| Item | What “done” looks like |
+|------|-------------------------|
+| **TLS pinning** | Pins for all API hosts; rotation runbook; optional `RemoteEndpointStore` pins from hosted config (see doc). |
+| **OIDC / AppAuth** | End-to-end code exchange; refresh; logout; token revocation policy. |
+| **Play Integrity** | `cloudProjectNumber` set; **backend** verifies tokens with Google API; abuse signals integrated. |
+| **Knox / MDM** | Knox SDK or UEM policies enforced (not just classpath detection). |
+| **BiometricSecretsVault** | Full `CryptoObject` path bound to refresh or high-value keys per your IdP design. |
+| **Classification marking** | `smoa.classification.buildMarking` aligned with organizational data labeling; content-level markings if required. |
+
+### Acceptance criteria
+
+- **Security architecture review** with traceability from threat model to controls.
+- **Operational runbooks** for rotation, incident, and user lockout.
+
+---
+
+## 8. End-to-end and pre-release testing
+
+### Current state (repository)
+
+- **Unit and integration tests** run via `./gradlew smoaVerify` (backend + Android unit + debug assemble).
+- E2E is **planned**, not implemented as an automated suite.
+
+**Reference:** [`docs/testing/E2E-PLAN.md`](../testing/E2E-PLAN.md).
+
+### Goal
+
+Repeatable **user-journey** validation before release (sync, auth, meetings, browser policy, etc.).
+
+### Deliverables
+
+1. Choose stack: **Maestro**, **Appium**, or **Espresso + MockWebServer** (per E2E-PLAN).
+2. **Seed data** and **test backend** (container or dedicated env).
+3. **Nightly** or **pre-release** pipeline; keep **smoaVerify** on every push.
+
+### Acceptance criteria
+
+- Critical paths (e.g. sign-in, directory pull, credential display) automated with **flake budget** and ownership.
+
+---
+
+## 9. iOS and additional clients
+
+### Current state (repository)
+
+- **Android** is the primary implementation; **iOS** documentation exists under `docs/ios/` as guidance/samples.
+
+### Goal (if in program scope)
+
+Feature parity and **shared contract** compliance for an iOS app or SDK.
+
+### Deliverables
+
+- Contract tests against same **OpenAPI**; shared **credential type** constants; platform-specific secure storage.
+
+---
+
+## 10. Formal assessments and authorization
+
+### Examples (deployment-dependent)
+
+- **CJIS** Security Policy compliance assessment.
+- **FedRAMP**, **StateRAMP**, **HIPAA**, **SOC 2**, or **department-specific ATO** packages.
+- **Country export** and **crypto** regulations for international deployments.
+
+### Deliverables
+
+- System security plan (SSP), control narratives, evidence from CI and ops.
+- POA&M for gaps discovered during assessment.
+
+---
+
+## 11. Suggested sequencing (non-binding)
+
+1. **Tenant isolation + auth model** (blocks safe multi-org production).
+2. **OpenAPI golden CI** (cheap win; prevents silent contract drift).
+3. **E2E smoke** on top 3 flows.
+4. **Barcode / issuance QA** for each in-scope credential type.
+5. **Agency integrations** (CJIS, ATF) as approvals arrive.
+6. **WebRTC** when meetings are production-critical.
+7. **Formal ATO** package in parallel with engineering hardening.
+
+---
+
+## Document control
+
+| Version | Date | Notes |
+|---------|------|--------|
+| 1.0 | 2026-03-23 | Initial consolidation of “not done here” items from gaps, threat model, backend roadmap, OpenAPI, E2E, security enterprise, infrastructure docs. |
+
+When a workstream is completed, update this file and trim or move the section to [`GAPS-AND-INCONSISTENCIES.md`](./GAPS-AND-INCONSISTENCIES.md) **Implemented** table as appropriate.
diff --git a/docs/status/IMPLEMENTATION_STATUS.md b/docs/status/IMPLEMENTATION_STATUS.md
index 98f4fae..4b029a1 100644
--- a/docs/status/IMPLEMENTATION_STATUS.md
+++ b/docs/status/IMPLEMENTATION_STATUS.md
@@ -1,8 +1,8 @@
# SMOA Implementation Status
-**Date:** 2024-12-20
-**Status:** ✅ **ALL CODE IMPLEMENTATION FRAMEWORKS COMPLETE**
-**Version:** 1.0
+**Date:** 2026-03-24
+**Status:** ✅ **Frameworks complete; integration and compliance work continues** (see [Remaining Work](#remaining-work))
+**Version:** 1.1
---
@@ -197,63 +197,26 @@ For detailed compliance information, see:
## Remaining Work
-**See [TODO.md](../../TODO.md)** (status vs external gates) and **[TASKS.md](../../TASKS.md)** (master task table with file links).
+**Primary index:** [TASKS.md](../../TASKS.md) (file-level status) · [TODO.md](../../TODO.md) (repo vs external gates) · [GAPS-AND-INCONSISTENCIES.md](../reference/GAPS-AND-INCONSISTENCIES.md).
-### Next steps (short-term)
+The Android app **already uses Retrofit** for sync/pull (`BackendSyncAPI`, `BackendPullAPI`, `SyncRetrofitHolder`). Backend **GET pull** and **POST sync** endpoints are implemented; see [backend/README.md](../../backend/README.md).
-1. **Backend:** Run `./gradlew :backend:test` and fix any failures; add integration tests for sync/pull/health.
-2. **Android 16:** When upgrading AGP to 8.5+, set `compileSdk = 36`, `targetSdk = 36` (see [ANDROID-16-TARGET.md](../reference/ANDROID-16-TARGET.md)).
-3. **Web:** Expand [web scaffold](../web-scaffold/index.html) (directory pull and status UI are in place); optional: React/Vue SPA, build pipeline, CORS in production.
-4. **iOS / Web Dapp:** Full apps are separate codebases; use [docs/ios/README.md](../ios/README.md) and web scaffold as starting points.
-5. **Domain/compliance:** NCIC, ATF, eIDAS QTSP, full WebRTC/AS4/signing require external approvals or larger implementations; extend stubs as needed.
+### Engineering backlog (in-repo)
-### High Priority (Future Enhancements)
+1. **Media / hardware:** WebRTC AAR + signaling; `SmartCardReader` PC/SC or OEM binding.
+2. **Backend:** Play Integrity token verification (Google API) — endpoint currently returns 501; row-level tenant isolation beyond `X-Unit` (see `TenantFilter` request attribute + threat model).
+3. **Trust:** OCSP client; online revocation policy tied to deployments.
+4. **Compliance QA:** AAMVA/ICAO official test vectors and jurisdiction sign-off ([GAPS](../reference/GAPS-AND-INCONSISTENCIES.md)).
+5. **Tests:** Android coverage toward 80%; E2E ([E2E-PLAN.md](../testing/E2E-PLAN.md)); more UI tests on emulator CI.
-1. **WebRTC Full Library Integration**
- - Integrate actual WebRTC library calls
- - Implement signaling server
- - Complete audio/video track setup
+### External or legal gates
-2. **Backend API Integration**
- - Connect SyncAPI to actual backend
- - Implement Retrofit interfaces
- - Add authentication headers
+- Live NCIC/III, ATF eTrace, eIDAS QTSP, full AS4 partner CPA, Knox binary, shipped Xcode iOS app, production CORS/hosting for web scaffold.
-3. **External API Integrations** (Requires Approval)
- - NCIC API integration (CJIS approval required)
- - ATF eTrace API (federal approval required)
- - eIDAS QTSP integration (provider selection required)
+### Maintenance
-### Medium Priority
-
-1. **Digital Signature Full Implementation**
- - BouncyCastle integration
- - Signature generation/verification
- - Certificate chain validation
-
-2. **XML Security**
- - Apache Santuario integration
- - XMLDSig implementation
- - XMLEnc implementation
-
-3. **Certificate Revocation**
- - OCSP client
- - CRL parsing
- - Revocation checking
-
-### Low Priority
-
-1. **Additional Test Coverage**
- - More unit tests for remaining modules
- - Integration tests
- - UI tests
- - End-to-end tests
- - Target: 80%+ coverage
-
-2. **Data Serialization**
- - Implement JSON serialization (Jackson/Gson)
- - Add data validation
- - Implement versioning
+- [COMPLIANCE_MATRIX.md](../reference/COMPLIANCE_MATRIX.md) aligned with this tree as of 2026-03-24; refresh after large feature drops.
+- **Android 16:** When upgrading AGP, follow [ANDROID-16-TARGET.md](../reference/ANDROID-16-TARGET.md).
---
@@ -282,9 +245,9 @@ For detailed compliance information, see:
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2024-12-20 | Consolidated IMPLEMENTATION_COMPLETE.md and IMPLEMENTATION_STATUS.md, added table of contents, cross-references, and current status |
+| 1.1 | 2026-03-24 | Remaining Work aligned with TASKS/TODO; Retrofit/sync corrections; compliance matrix refresh reference |
---
-**Last Updated:** 2024-12-20
-**Status:** All Implementation Frameworks Complete
+**Last Updated:** 2026-03-24
**Next Review:** Quarterly
diff --git a/modules/atf/build.gradle.kts b/modules/atf/build.gradle.kts
index 25f5e67..be65cbf 100644
--- a/modules/atf/build.gradle.kts
+++ b/modules/atf/build.gradle.kts
@@ -48,6 +48,11 @@ dependencies {
implementation(Dependencies.composeUi)
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
+ implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.androidxLifecycleRuntimeCompose)
+ implementation(Dependencies.hiltNavigationCompose)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
diff --git a/modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt b/modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt
index a561b90..a07f30b 100644
--- a/modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt
+++ b/modules/atf/src/main/java/com/smoa/modules/atf/ATFModule.kt
@@ -1,25 +1,83 @@
package com.smoa.modules.atf
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.smoa.modules.atf.ui.AtfViewModel
@Composable
-fun ATFModule(modifier: Modifier = Modifier) {
+fun ATFModule(
+ modifier: Modifier = Modifier,
+ viewModel: AtfViewModel = hiltViewModel()
+) {
+ var tab by rememberSaveable { mutableIntStateOf(0) }
+ var serial by rememberSaveable { mutableStateOf("SN-DEMO-001") }
+ var name by rememberSaveable { mutableStateOf("Alex Demo") }
+ val msg by viewModel.message.collectAsStateWithLifecycle()
+
Column(
modifier = modifier
.fillMaxSize()
- .padding(16.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
- Text(
- text = "ATF Forms",
- style = MaterialTheme.typography.headlineMedium
- )
+ Text("ATF forms", style = MaterialTheme.typography.headlineSmall)
+ TabRow(selectedTabIndex = tab) {
+ Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("4473") })
+ Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Form 1") })
+ Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("Form 4") })
+ }
+ when (tab) {
+ 0 -> {
+ OutlinedTextField(
+ serial,
+ { serial = it },
+ label = { Text("Firearm serial") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ name,
+ { name = it },
+ label = { Text("Transferee name") },
+ modifier = Modifier.fillMaxWidth()
+ )
+ Button(
+ onClick = { viewModel.submit4473Demo(serial.trim(), name.trim()) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Validate & submit (simulated eTrace)")
+ }
+ }
+ 1 -> Text(
+ "NFA Form 1 (manufacture) — data models and validation live in domain; wire eForms when approved.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ else -> Text(
+ "NFA Form 4 (transfer) — data models and validation live in domain; wire eForms when approved.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ msg?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}
-
diff --git a/modules/atf/src/main/java/com/smoa/modules/atf/ui/AtfViewModel.kt b/modules/atf/src/main/java/com/smoa/modules/atf/ui/AtfViewModel.kt
new file mode 100644
index 0000000..59d4d34
--- /dev/null
+++ b/modules/atf/src/main/java/com/smoa/modules/atf/ui/AtfViewModel.kt
@@ -0,0 +1,60 @@
+package com.smoa.modules.atf.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.modules.atf.domain.ATFForm4473
+import com.smoa.modules.atf.domain.ATFService
+import com.smoa.modules.atf.domain.FirearmType
+import com.smoa.modules.atf.domain.FormStatus
+import com.smoa.modules.atf.domain.PersonInfo
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.util.Date
+import java.util.UUID
+import javax.inject.Inject
+
+@HiltViewModel
+class AtfViewModel @Inject constructor(
+ private val atfService: ATFService
+) : ViewModel() {
+
+ private val _message = MutableStateFlow(null)
+ val message: StateFlow = _message.asStateFlow()
+
+ fun submit4473Demo(serial: String, transfereeName: String) {
+ viewModelScope.launch {
+ val dob = Date(631152000000L) // 1990-ish
+ val person = PersonInfo(
+ name = transfereeName,
+ address = "100 Demo St",
+ city = "Washington",
+ state = "DC",
+ zipCode = "20001",
+ dateOfBirth = dob,
+ socialSecurityNumber = "1234"
+ )
+ val form = ATFForm4473(
+ formId = UUID.randomUUID().toString(),
+ transactionDate = Date(),
+ firearmManufacturer = "Demo Arms",
+ firearmModel = "M-1",
+ firearmSerialNumber = serial,
+ firearmCaliber = "9mm",
+ firearmType = FirearmType.HANDGUN,
+ transfereeInfo = person,
+ transferorInfo = person.copy(name = "Demo FFL"),
+ nicsCheckNumber = "NICS-DEMO",
+ nicsCheckDate = Date(),
+ signatures = emptyList(),
+ status = FormStatus.DRAFT
+ )
+ atfService.submitForm4473(form).fold(
+ onSuccess = { _message.value = "Submitted ${it.submissionId} (${it.status})" },
+ onFailure = { _message.value = it.message ?: "Submit failed" }
+ )
+ }
+ }
+}
diff --git a/modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt b/modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt
index a18947b..402319a 100644
--- a/modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt
+++ b/modules/credentials/src/main/java/com/smoa/modules/credentials/data/CredentialCacheMerger.kt
@@ -2,6 +2,7 @@ package com.smoa.modules.credentials.data
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
+import com.smoa.core.common.Result
import com.smoa.core.common.sync.CredentialCacheSyncPort
import com.smoa.core.common.sync.CredentialConflictRemoteJson
import com.smoa.core.common.sync.CredentialSyncRequestDto
@@ -73,6 +74,21 @@ class CredentialCacheMerger @Inject constructor(
}
}
+ override suspend fun hydrateMissingPayloads(
+ pullCredentialDetail: suspend (String) -> Result
+ ) {
+ withContext(Dispatchers.IO) {
+ for (row in dao.getAll()) {
+ if (row.payloadJson.isNullOrBlank()) {
+ when (val r = pullCredentialDetail(row.credentialId)) {
+ is Result.Success -> mergeDetailJson(r.data)
+ is Result.Error, Result.Loading -> Unit
+ }
+ }
+ }
+ }
+ }
+
override suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) {
withContext(Dispatchers.IO) {
val dto = when (data) {
diff --git a/modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt b/modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt
index 1e2c0f8..65a003a 100644
--- a/modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt
+++ b/modules/credentials/src/main/java/com/smoa/modules/credentials/ui/CredentialsViewModel.kt
@@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.smoa.core.common.ConnectivityManager
import com.smoa.core.common.PullAPI
-import com.smoa.core.common.Result
import com.smoa.core.common.SyncItem
import com.smoa.core.common.SyncItemType
import com.smoa.core.common.SyncOperation
@@ -42,29 +41,18 @@ class CredentialsViewModel @Inject constructor(
_selectedId.value = id
}
- fun hydrateMissingPayloads() {
- viewModelScope.launch(Dispatchers.IO) {
- hydratePayloadsIfMissing()
- }
- }
-
fun refreshFromServer() {
viewModelScope.launch(Dispatchers.IO) {
if (!connectivityManager.isOnline()) return@launch
syncService.startSync()
- hydratePayloadsIfMissing()
}
}
- private suspend fun hydratePayloadsIfMissing() {
- if (!connectivityManager.isOnline()) return
- for (row in dao.getAll()) {
- if (row.payloadJson.isNullOrBlank()) {
- when (val r = pullAPI.pullCredentialDetail(row.credentialId)) {
- is Result.Success -> merger.mergeDetailJson(r.data)
- else -> Unit
- }
- }
+ /** Re-fetch payloads for cached rows that only have list metadata (e.g. after offline period). */
+ fun hydrateMissingPayloads() {
+ viewModelScope.launch(Dispatchers.IO) {
+ if (!connectivityManager.isOnline()) return@launch
+ merger.hydrateMissingPayloads { pullAPI.pullCredentialDetail(it) }
}
}
diff --git a/modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt b/modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt
index a0c0c5c..1f931d3 100644
--- a/modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt
+++ b/modules/credentials/src/test/java/com/smoa/modules/credentials/data/CredentialCacheMergerTest.kt
@@ -1,6 +1,7 @@
package com.smoa.modules.credentials.data
import com.google.gson.Gson
+import com.smoa.core.common.Result
import com.smoa.core.common.sync.CredentialSyncRequestDto
import io.mockk.coEvery
import io.mockk.coVerify
@@ -9,6 +10,7 @@ import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
import org.junit.Test
class CredentialCacheMergerTest {
@@ -120,6 +122,34 @@ class CredentialCacheMergerTest {
assertEquals("x", slot.captured.credentialId)
}
+ @Test
+ fun `hydrateMissingPayloads pulls detail when payload empty`() = runTest {
+ val row = CredentialCacheEntity(
+ credentialId = "c1",
+ holderId = "h",
+ credentialType = "T",
+ issuer = null,
+ issuedAt = null,
+ expiresAt = null,
+ payloadJson = null,
+ updatedAt = 1L
+ )
+ coEvery { dao.getAll() } returns listOf(row)
+ val detailJson =
+ """{"credentialId":"c1","holderId":"h","credentialType":"T","issuer":null,"issuedAt":1,"expiresAt":2,"payloadJson":"{\"x\":1}","updatedAt":3}"""
+ .toByteArray(Charsets.UTF_8)
+
+ merger.hydrateMissingPayloads { id ->
+ assertEquals("c1", id)
+ Result.Success(detailJson)
+ }
+
+ val slot = slot()
+ coVerify { dao.upsert(capture(slot)) }
+ assertEquals("c1", slot.captured.credentialId)
+ assertTrue(slot.captured.payloadJson!!.contains("x"))
+ }
+
@Test
fun `mergeCredentialConflictRemoteData upserts server snapshot`() = runTest {
val map = mapOf(
diff --git a/modules/evidence/build.gradle.kts b/modules/evidence/build.gradle.kts
index eab52c2..67da754 100644
--- a/modules/evidence/build.gradle.kts
+++ b/modules/evidence/build.gradle.kts
@@ -50,6 +50,10 @@ dependencies {
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.androidxLifecycleRuntimeCompose)
+ implementation(Dependencies.hiltNavigationCompose)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/data/EvidenceDao.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/data/EvidenceDao.kt
index 8c42963..cceeaa8 100644
--- a/modules/evidence/src/main/java/com/smoa/modules/evidence/data/EvidenceDao.kt
+++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/data/EvidenceDao.kt
@@ -42,6 +42,9 @@ interface CustodyTransferDao {
@Query("SELECT * FROM custody_transfers WHERE evidenceId = :evidenceId ORDER BY timestamp ASC")
fun getChainOfCustody(evidenceId: String): Flow>
+ @Query("SELECT * FROM custody_transfers ORDER BY timestamp ASC")
+ fun observeAllTransfers(): Flow>
+
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTransfer(transfer: CustodyTransferEntity)
diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceRepository.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceRepository.kt
index 0465767..e501561 100644
--- a/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceRepository.kt
+++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceRepository.kt
@@ -5,6 +5,7 @@ import com.smoa.modules.evidence.data.EvidenceDao
import com.smoa.modules.evidence.data.EvidenceEntity
import com.smoa.modules.evidence.data.CustodyTransferEntity
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.util.Date
import java.util.UUID
@@ -25,14 +26,20 @@ class EvidenceRepository @Inject constructor(
suspend fun getEvidenceById(evidenceId: String): Evidence? {
val entity = evidenceDao.getEvidenceById(evidenceId) ?: return null
- val transfers = custodyTransferDao.getChainOfCustody(evidenceId)
- // Convert Flow to List (simplified - in production use proper async handling)
- return entity.toDomain(emptyList()) // Will need to load transfers separately
+ return entity.toDomain(emptyList())
}
fun getEvidenceByCase(caseNumber: String): Flow> {
- return evidenceDao.getEvidenceByCase(caseNumber).map { entities ->
- entities.map { it.toDomain(emptyList()) }
+ return combine(
+ evidenceDao.getEvidenceByCase(caseNumber),
+ custodyTransferDao.observeAllTransfers()
+ ) { entities, transfers ->
+ entities.map { e ->
+ val chain = transfers
+ .filter { it.evidenceId == e.evidenceId }
+ .map { it.toDomain() }
+ e.toDomain(chain)
+ }
}
}
@@ -40,8 +47,8 @@ class EvidenceRepository @Inject constructor(
evidenceDao.insertEvidence(evidence.toEntity())
}
- suspend fun addCustodyTransfer(transfer: CustodyTransfer) {
- custodyTransferDao.insertTransfer(transfer.toEntity())
+ suspend fun addCustodyTransfer(evidenceId: String, transfer: CustodyTransfer) {
+ custodyTransferDao.insertTransfer(transfer.toEntity(evidenceId))
}
fun getChainOfCustody(evidenceId: String): Flow> {
@@ -104,10 +111,10 @@ private fun CustodyTransferEntity.toDomain(): CustodyTransfer {
)
}
-private fun CustodyTransfer.toEntity(): CustodyTransferEntity {
+private fun CustodyTransfer.toEntity(evidenceId: String): CustodyTransferEntity {
return CustodyTransferEntity(
transferId = transferId,
- evidenceId = "", // Should be set by caller
+ evidenceId = evidenceId,
timestamp = timestamp,
fromCustodian = fromCustodian,
toCustodian = toCustodian,
diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceService.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceService.kt
index e46911a..fd17f10 100644
--- a/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceService.kt
+++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/domain/EvidenceService.kt
@@ -75,7 +75,7 @@ class EvidenceService @Inject constructor(
)
// In production, update evidence currentCustodian
- repository.addCustodyTransfer(transfer)
+ repository.addCustodyTransfer(evidenceId, transfer)
auditLogger.logEvent(
AuditEventType.CREDENTIAL_ACCESS,
userId = fromCustodian,
diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceDetailScreen.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceDetailScreen.kt
new file mode 100644
index 0000000..fe31c90
--- /dev/null
+++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceDetailScreen.kt
@@ -0,0 +1,73 @@
+package com.smoa.modules.evidence.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+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.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.smoa.modules.evidence.domain.Evidence
+
+@Composable
+fun EvidenceDetailScreen(
+ evidence: Evidence,
+ currentUserId: String,
+ onTransfer: (toCustodian: String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ var toName by rememberSaveable { mutableStateOf("custodian-b") }
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(evidence.caseNumber, style = MaterialTheme.typography.headlineSmall)
+ Text(evidence.description, style = MaterialTheme.typography.bodyLarge)
+ Text(
+ "Type ${evidence.evidenceType.name} · Collected by ${evidence.collectedBy}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text("Current custodian: ${evidence.currentCustodian}", style = MaterialTheme.typography.titleSmall)
+ Text("Chain of custody (logged transfers)", style = MaterialTheme.typography.labelLarge)
+ if (evidence.chainOfCustody.isEmpty()) {
+ Text("No transfers yet.", style = MaterialTheme.typography.bodySmall)
+ } else {
+ evidence.chainOfCustody.forEach { t ->
+ Text(
+ "→ ${t.timestamp} : ${t.fromCustodian} to ${t.toCustodian} (${t.reason})",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ }
+ }
+ OutlinedTextField(
+ value = toName,
+ onValueChange = { toName = it },
+ label = { Text("Transfer to (user id)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Button(
+ onClick = { onTransfer(toName.trim().ifBlank { "custodian-b" }) },
+ enabled = currentUserId.isNotBlank(),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Log custody transfer")
+ }
+ }
+}
diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceListScreen.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceListScreen.kt
index 36b3d30..e8b0e05 100644
--- a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceListScreen.kt
+++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceListScreen.kt
@@ -1,25 +1,75 @@
package com.smoa.modules.evidence.ui
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.smoa.modules.evidence.domain.Evidence
@Composable
-fun EvidenceListScreen(modifier: Modifier = Modifier) {
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(16.dp)
+fun EvidenceListScreen(
+ items: List,
+ onSelect: (Evidence) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ if (items.isEmpty()) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ "No evidence records",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ "Add a sample item to begin the chain-of-custody workflow.",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ return
+ }
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- Text(
- text = "Evidence",
- style = MaterialTheme.typography.headlineMedium
- )
+ items(items, key = { it.evidenceId }) { e ->
+ Card(
+ Modifier
+ .fillMaxWidth()
+ .clickable { onSelect(e) },
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Text(e.caseNumber, style = MaterialTheme.typography.titleMedium)
+ Text(
+ "${e.evidenceType.name} · ${e.description}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ "Custodian: ${e.currentCustodian}",
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ }
+ }
}
}
-
diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceModule.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceModule.kt
index 1ab09c5..67fa8f7 100644
--- a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceModule.kt
+++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceModule.kt
@@ -1,25 +1,91 @@
package com.smoa.modules.evidence.ui
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import kotlinx.coroutines.launch
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun EvidenceModule(modifier: Modifier = Modifier) {
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(16.dp)
- ) {
- Text(
- text = "Evidence Chain of Custody",
- style = MaterialTheme.typography.headlineMedium
- )
+fun EvidenceModule(
+ userId: String,
+ modifier: Modifier = Modifier,
+ viewModel: EvidenceViewModel = hiltViewModel()
+) {
+ val items by viewModel.evidenceItems.collectAsStateWithLifecycle()
+ var selectedId: String? by remember { mutableStateOf(null) }
+ val selected = selectedId?.let { id -> items.firstOrNull { it.evidenceId == id } }
+ val snack = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
+
+ Scaffold(
+ modifier = modifier,
+ snackbarHost = { SnackbarHost(snack) },
+ topBar = {
+ TopAppBar(
+ title = { Text(if (selected == null) "Evidence" else "Evidence detail") },
+ navigationIcon = {
+ if (selected != null) {
+ IconButton(onClick = { selectedId = null }) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ if (selectedId == null) {
+ FloatingActionButton(
+ onClick = {
+ viewModel.createSample(userId) { err ->
+ scope.launch {
+ snack.showSnackbar(err ?: "Sample evidence created")
+ }
+ }
+ }
+ ) {
+ Icon(Icons.Default.Add, contentDescription = "Add sample")
+ }
+ }
+ }
+ ) { padding ->
+ when (val e = selected) {
+ null -> EvidenceListScreen(
+ items = items,
+ onSelect = { selectedId = it.evidenceId },
+ modifier = Modifier.padding(padding)
+ )
+ else -> EvidenceDetailScreen(
+ evidence = e,
+ currentUserId = userId,
+ onTransfer = { to ->
+ viewModel.transferTo(e.evidenceId, userId, to) { err ->
+ scope.launch {
+ snack.showSnackbar(err ?: "Transfer logged")
+ }
+ }
+ },
+ modifier = Modifier.padding(padding)
+ )
+ }
}
}
-
diff --git a/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceViewModel.kt b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceViewModel.kt
new file mode 100644
index 0000000..4ad2590
--- /dev/null
+++ b/modules/evidence/src/main/java/com/smoa/modules/evidence/ui/EvidenceViewModel.kt
@@ -0,0 +1,61 @@
+package com.smoa.modules.evidence.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.modules.evidence.domain.DigitalSignature
+import com.smoa.modules.evidence.domain.Evidence
+import com.smoa.modules.evidence.domain.EvidenceService
+import com.smoa.modules.evidence.domain.EvidenceType
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import java.util.Date
+import java.util.UUID
+import javax.inject.Inject
+
+@HiltViewModel
+class EvidenceViewModel @Inject constructor(
+ private val evidenceService: EvidenceService
+) : ViewModel() {
+
+ val evidenceItems: StateFlow> = evidenceService.getAllEvidence()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ fun createSample(userId: String, onResult: (String?) -> Unit) {
+ viewModelScope.launch {
+ val r = evidenceService.createEvidence(
+ caseNumber = "DEMO-${System.currentTimeMillis() % 100000}",
+ description = "Sample evidence item for demonstration.",
+ evidenceType = EvidenceType.DOCUMENT,
+ collectionLocation = "Lab / field office",
+ collectionMethod = "Digital import",
+ collectedBy = userId,
+ )
+ onResult(r.exceptionOrNull()?.message)
+ }
+ }
+
+ fun transferTo(evidenceId: String, fromUser: String, toUser: String, onResult: (String?) -> Unit) {
+ viewModelScope.launch {
+ val sig = DigitalSignature(
+ signatureId = UUID.randomUUID().toString(),
+ signerId = fromUser,
+ signerName = fromUser,
+ signatureDate = Date(),
+ signatureData = byteArrayOf()
+ )
+ val r = evidenceService.transferCustody(
+ evidenceId = evidenceId,
+ fromCustodian = fromUser,
+ toCustodian = toUser,
+ reason = "Custody transfer (demo)",
+ evidenceCondition = "Sealed / intact",
+ signature = sig,
+ notes = null
+ )
+ onResult(r.exceptionOrNull()?.message)
+ }
+ }
+}
diff --git a/modules/intelligence/build.gradle.kts b/modules/intelligence/build.gradle.kts
index 625cb46..f513736 100644
--- a/modules/intelligence/build.gradle.kts
+++ b/modules/intelligence/build.gradle.kts
@@ -48,6 +48,11 @@ dependencies {
implementation(Dependencies.composeUi)
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
+ implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.androidxLifecycleRuntimeCompose)
+ implementation(Dependencies.hiltNavigationCompose)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
diff --git a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/IntelligenceModule.kt b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/IntelligenceModule.kt
index 4396aa4..a02085d 100644
--- a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/IntelligenceModule.kt
+++ b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/IntelligenceModule.kt
@@ -1,25 +1,80 @@
package com.smoa.modules.intelligence
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.smoa.modules.intelligence.ui.IntelligenceViewModel
@Composable
-fun IntelligenceModule(modifier: Modifier = Modifier) {
+fun IntelligenceModule(
+ userId: String,
+ modifier: Modifier = Modifier,
+ viewModel: IntelligenceViewModel = hiltViewModel()
+) {
+ var compartmentId by rememberSaveable { mutableStateOf("") }
+ val lines by viewModel.lines.collectAsStateWithLifecycle()
+
Column(
modifier = modifier
.fillMaxSize()
- .padding(16.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
) {
+ Text("Intelligence (MLS)", style = MaterialTheme.typography.headlineSmall)
Text(
- text = "Intelligence Operations",
- style = MaterialTheme.typography.headlineMedium
+ "Compartments, need-to-know, and protected sources.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
+ Button(onClick = { viewModel.registerDemoCompartment() }, modifier = Modifier.fillMaxWidth()) {
+ Text("Register demo compartment")
+ }
+ OutlinedTextField(
+ compartmentId,
+ { compartmentId = it },
+ label = { Text("Compartment id") },
+ placeholder = { Text("Use id from list below") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Button(
+ onClick = { viewModel.grantDemoAccess(userId, compartmentId.trim()) },
+ enabled = compartmentId.isNotBlank(),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Grant access + NTK (demo)")
+ }
+ Button(
+ onClick = { viewModel.checkAccess(userId, compartmentId.trim()) },
+ enabled = compartmentId.isNotBlank(),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Check access")
+ }
+ Button(onClick = { viewModel.createDemoSource(userId) }, modifier = Modifier.fillMaxWidth()) {
+ Text("Create demo protected source")
+ }
+ Text("Compartments", style = MaterialTheme.typography.labelLarge)
+ lines.forEach { line ->
+ Text(line, style = MaterialTheme.typography.bodySmall)
+ }
}
}
-
diff --git a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/CompartmentManager.kt b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/CompartmentManager.kt
index b2721c1..bc65208 100644
--- a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/CompartmentManager.kt
+++ b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/CompartmentManager.kt
@@ -19,6 +19,9 @@ class CompartmentManager @Inject constructor() {
fun registerCompartment(compartment: Compartment) {
compartments[compartment.compartmentId] = compartment
}
+
+ /** Snapshot for UI / diagnostics (in-memory only). */
+ fun getRegisteredCompartments(): List = compartments.values.toList()
/**
* Check if user has access to compartment.
diff --git a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/IntelligenceService.kt b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/IntelligenceService.kt
index 2928844..cbe7955 100644
--- a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/IntelligenceService.kt
+++ b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/domain/IntelligenceService.kt
@@ -36,6 +36,26 @@ class IntelligenceService @Inject constructor(
return compartmentManager.hasAccess(userId, compartmentId) &&
compartmentManager.hasNeedToKnow(userId, compartmentId)
}
+
+ fun listRegisteredCompartments(): List =
+ compartmentManager.getRegisteredCompartments()
+
+ fun grantCompartmentAccess(userId: String, compartmentId: String) {
+ compartmentManager.grantAccess(userId, compartmentId)
+ }
+
+ fun authorizeNeedToKnow(userId: String, compartmentId: String) {
+ compartmentManager.addNeedToKnow(
+ NeedToKnow(
+ compartmentId = compartmentId,
+ userId = userId,
+ justification = "Operational authorization",
+ authorizedBy = "security_officer",
+ authorizationDate = Date(),
+ expirationDate = null
+ )
+ )
+ }
/**
* Create protected source.
diff --git a/modules/intelligence/src/main/java/com/smoa/modules/intelligence/ui/IntelligenceViewModel.kt b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/ui/IntelligenceViewModel.kt
new file mode 100644
index 0000000..4575b0e
--- /dev/null
+++ b/modules/intelligence/src/main/java/com/smoa/modules/intelligence/ui/IntelligenceViewModel.kt
@@ -0,0 +1,77 @@
+package com.smoa.modules.intelligence.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.modules.intelligence.domain.AccessLevel
+import com.smoa.modules.intelligence.domain.Compartment
+import com.smoa.modules.intelligence.domain.IntelligenceService
+import com.smoa.modules.intelligence.domain.ProtectionLevel
+import com.smoa.modules.intelligence.domain.SourceType
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.util.UUID
+import javax.inject.Inject
+
+@HiltViewModel
+class IntelligenceViewModel @Inject constructor(
+ private val intelligenceService: IntelligenceService
+) : ViewModel() {
+
+ private val _lines = MutableStateFlow>(emptyList())
+ val lines: StateFlow> = _lines.asStateFlow()
+
+ private fun refresh() {
+ _lines.value = intelligenceService.listRegisteredCompartments()
+ .map { "${it.compartmentId} · ${it.name} (${it.accessLevel.name})" }
+ }
+
+ init {
+ refresh()
+ }
+
+ fun registerDemoCompartment() {
+ viewModelScope.launch {
+ val id = "CMP-${UUID.randomUUID().toString().take(8).uppercase()}"
+ intelligenceService.registerCompartment(
+ Compartment(
+ compartmentId = id,
+ name = "Demo compartment",
+ description = "MLS demo registration",
+ accessLevel = AccessLevel.SECRET,
+ controllingAgency = "DEMO-IC",
+ authorizedPersonnel = emptyList()
+ )
+ )
+ refresh()
+ }
+ }
+
+ fun grantDemoAccess(userId: String, compartmentId: String) {
+ intelligenceService.grantCompartmentAccess(userId, compartmentId)
+ intelligenceService.authorizeNeedToKnow(userId, compartmentId)
+ _lines.value = _lines.value + "Access + NTK granted for $userId on $compartmentId"
+ }
+
+ fun checkAccess(userId: String, compartmentId: String) {
+ val ok = intelligenceService.checkCompartmentAccess(userId, compartmentId)
+ _lines.value = _lines.value + "Access check $compartmentId: $ok"
+ }
+
+ fun createDemoSource(userId: String) {
+ viewModelScope.launch {
+ intelligenceService.createProtectedSource(
+ sourceType = SourceType.HUMAN_INTELLIGENCE,
+ codename = "DEMO-SRC",
+ description = "Demonstration protected source record",
+ protectionLevel = ProtectionLevel.HIGHLY_SENSITIVE,
+ authorizedHandlers = listOf(userId)
+ ).fold(
+ onSuccess = { s -> _lines.value = _lines.value + "Source ${s.sourceId} created" },
+ onFailure = { e -> _lines.value = _lines.value + (e.message ?: "source error") }
+ )
+ }
+ }
+}
diff --git a/modules/judicial/build.gradle.kts b/modules/judicial/build.gradle.kts
index e4a02a5..a6aed78 100644
--- a/modules/judicial/build.gradle.kts
+++ b/modules/judicial/build.gradle.kts
@@ -50,6 +50,11 @@ dependencies {
implementation(Dependencies.composeUi)
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
+ implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.androidxLifecycleRuntimeCompose)
+ implementation(Dependencies.hiltNavigationCompose)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
diff --git a/modules/judicial/src/main/java/com/smoa/modules/judicial/JudicialModule.kt b/modules/judicial/src/main/java/com/smoa/modules/judicial/JudicialModule.kt
index 18004c5..bc1cacd 100644
--- a/modules/judicial/src/main/java/com/smoa/modules/judicial/JudicialModule.kt
+++ b/modules/judicial/src/main/java/com/smoa/modules/judicial/JudicialModule.kt
@@ -1,25 +1,54 @@
package com.smoa.modules.judicial
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.smoa.modules.judicial.ui.JudicialViewModel
@Composable
-fun JudicialModule(modifier: Modifier = Modifier) {
+fun JudicialModule(
+ modifier: Modifier = Modifier,
+ viewModel: JudicialViewModel = hiltViewModel()
+) {
+ var caseNo by rememberSaveable { mutableStateOf("CR-2025-DEMO") }
+ var title by rememberSaveable { mutableStateOf("Demonstration court order") }
+ val summary by viewModel.summary.collectAsStateWithLifecycle()
+
Column(
modifier = modifier
.fillMaxSize()
- .padding(16.dp)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
+ Text("Judicial", style = MaterialTheme.typography.headlineSmall)
Text(
- text = "Judicial Operations",
- style = MaterialTheme.typography.headlineMedium
+ "Court orders, case files, and subpoenas (domain services).",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
+ OutlinedTextField(caseNo, { caseNo = it }, label = { Text("Case number") }, modifier = Modifier.fillMaxWidth())
+ OutlinedTextField(title, { title = it }, label = { Text("Title") }, modifier = Modifier.fillMaxWidth())
+ Button(
+ onClick = { viewModel.createDemoOrder(caseNo.trim(), title.trim()) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Create demo court order")
+ }
+ summary?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}
-
diff --git a/modules/judicial/src/main/java/com/smoa/modules/judicial/ui/JudicialViewModel.kt b/modules/judicial/src/main/java/com/smoa/modules/judicial/ui/JudicialViewModel.kt
new file mode 100644
index 0000000..a4e95bf
--- /dev/null
+++ b/modules/judicial/src/main/java/com/smoa/modules/judicial/ui/JudicialViewModel.kt
@@ -0,0 +1,40 @@
+package com.smoa.modules.judicial.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.modules.judicial.domain.CourtOrderType
+import com.smoa.modules.judicial.domain.JudicialService
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.util.Date
+import javax.inject.Inject
+
+@HiltViewModel
+class JudicialViewModel @Inject constructor(
+ private val judicialService: JudicialService
+) : ViewModel() {
+
+ private val _summary = MutableStateFlow(null)
+ val summary: StateFlow = _summary.asStateFlow()
+
+ fun createDemoOrder(caseNumber: String, title: String) {
+ viewModelScope.launch {
+ judicialService.createCourtOrder(
+ courtName = "Demo District Court",
+ caseNumber = caseNumber,
+ orderType = CourtOrderType.COURT_ORDER,
+ title = title,
+ content = "Demonstration judicial order content.",
+ judgeName = "Hon. Demo Judge",
+ effectiveDate = Date(),
+ expirationDate = null
+ ).fold(
+ onSuccess = { o -> _summary.value = "${o.orderId} · ${o.caseNumber} · ${o.status.name}" },
+ onFailure = { e -> _summary.value = e.message ?: "Failed" }
+ )
+ }
+ }
+}
diff --git a/modules/military/build.gradle.kts b/modules/military/build.gradle.kts
index ae76625..62e4331 100644
--- a/modules/military/build.gradle.kts
+++ b/modules/military/build.gradle.kts
@@ -49,6 +49,11 @@ dependencies {
implementation(Dependencies.composeUi)
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
+ implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.androidxLifecycleRuntimeCompose)
+ implementation(Dependencies.hiltNavigationCompose)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
diff --git a/modules/military/src/main/java/com/smoa/modules/military/MilitaryModule.kt b/modules/military/src/main/java/com/smoa/modules/military/MilitaryModule.kt
index 8bb0b01..5178f77 100644
--- a/modules/military/src/main/java/com/smoa/modules/military/MilitaryModule.kt
+++ b/modules/military/src/main/java/com/smoa/modules/military/MilitaryModule.kt
@@ -1,25 +1,54 @@
package com.smoa.modules.military
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.smoa.modules.military.ui.MilitaryViewModel
@Composable
-fun MilitaryModule(modifier: Modifier = Modifier) {
+fun MilitaryModule(
+ modifier: Modifier = Modifier,
+ viewModel: MilitaryViewModel = hiltViewModel()
+) {
+ var last by rememberSaveable { mutableStateOf("Doe") }
+ var first by rememberSaveable { mutableStateOf("Jane") }
+ val summary by viewModel.credentialSummary.collectAsStateWithLifecycle()
+
Column(
modifier = modifier
.fillMaxSize()
- .padding(16.dp)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
+ Text("Military credentials", style = MaterialTheme.typography.headlineSmall)
Text(
- text = "Military Operations",
- style = MaterialTheme.typography.headlineMedium
+ "MIL-STD aligned credential record (demo issuance).",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
+ OutlinedTextField(last, { last = it }, label = { Text("Last name") }, modifier = Modifier.fillMaxWidth())
+ OutlinedTextField(first, { first = it }, label = { Text("First name") }, modifier = Modifier.fillMaxWidth())
+ Button(
+ onClick = { viewModel.createDemoCredential(last.trim(), first.trim()) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Issue demo credential")
+ }
+ summary?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}
-
diff --git a/modules/military/src/main/java/com/smoa/modules/military/ui/MilitaryViewModel.kt b/modules/military/src/main/java/com/smoa/modules/military/ui/MilitaryViewModel.kt
new file mode 100644
index 0000000..d8116e0
--- /dev/null
+++ b/modules/military/src/main/java/com/smoa/modules/military/ui/MilitaryViewModel.kt
@@ -0,0 +1,43 @@
+package com.smoa.modules.military.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.modules.military.domain.ClearanceLevel
+import com.smoa.modules.military.domain.MilitaryService
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.util.Date
+import javax.inject.Inject
+
+@HiltViewModel
+class MilitaryViewModel @Inject constructor(
+ private val militaryService: MilitaryService
+) : ViewModel() {
+
+ private val _credentialSummary = MutableStateFlow(null)
+ val credentialSummary: StateFlow = _credentialSummary.asStateFlow()
+
+ fun createDemoCredential(lastName: String, firstName: String) {
+ viewModelScope.launch {
+ militaryService.createMilitaryCredential(
+ serviceCode = "USA",
+ rank = "CPT",
+ lastName = lastName,
+ firstName = firstName,
+ socialSecurityNumber = "XXX-XX-0000",
+ dateOfBirth = Date(631152000000L),
+ expirationDate = Date(System.currentTimeMillis() + 86400000L * 365 * 5),
+ unit = "1-DEMO",
+ clearanceLevel = ClearanceLevel.SECRET
+ ).fold(
+ onSuccess = { c ->
+ _credentialSummary.value = "${c.credentialId} · ${c.serviceCode} ${c.rank} ${c.lastName}"
+ },
+ onFailure = { e -> _credentialSummary.value = e.message ?: "Failed" }
+ )
+ }
+ }
+}
diff --git a/modules/ncic/build.gradle.kts b/modules/ncic/build.gradle.kts
index f32e734..b6f9636 100644
--- a/modules/ncic/build.gradle.kts
+++ b/modules/ncic/build.gradle.kts
@@ -48,6 +48,11 @@ dependencies {
implementation(Dependencies.composeUi)
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
+ implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.androidxLifecycleRuntimeCompose)
+ implementation(Dependencies.hiltNavigationCompose)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
diff --git a/modules/ncic/src/main/java/com/smoa/modules/ncic/NCICModule.kt b/modules/ncic/src/main/java/com/smoa/modules/ncic/NCICModule.kt
index d1f513c..427b94c 100644
--- a/modules/ncic/src/main/java/com/smoa/modules/ncic/NCICModule.kt
+++ b/modules/ncic/src/main/java/com/smoa/modules/ncic/NCICModule.kt
@@ -1,25 +1,25 @@
package com.smoa.modules.ncic
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.smoa.modules.ncic.ui.NCICQueryScreen
+import com.smoa.modules.ncic.ui.NcicViewModel
@Composable
-fun NCICModule(modifier: Modifier = Modifier) {
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(16.dp)
- ) {
- Text(
- text = "NCIC/III Integration",
- style = MaterialTheme.typography.headlineMedium
- )
- }
+fun NCICModule(
+ userId: String,
+ modifier: Modifier = Modifier,
+ viewModel: NcicViewModel = hiltViewModel()
+) {
+ val result by viewModel.result.collectAsStateWithLifecycle()
+ NCICQueryScreen(
+ operatorId = userId,
+ onRunQuery = { ori, ucn, type -> viewModel.runQuery(ori, ucn, type, userId) },
+ resultText = result,
+ modifier = modifier.fillMaxSize()
+ )
}
-
diff --git a/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NCICQueryScreen.kt b/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NCICQueryScreen.kt
index bc6b098..fd4356a 100644
--- a/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NCICQueryScreen.kt
+++ b/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NCICQueryScreen.kt
@@ -1,25 +1,88 @@
package com.smoa.modules.ncic.ui
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.ui.Alignment
+import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.smoa.modules.ncic.domain.NCICQueryType
@Composable
-fun NCICQueryScreen(modifier: Modifier = Modifier) {
+fun NCICQueryScreen(
+ operatorId: String,
+ onRunQuery: (ori: String, ucn: String, type: NCICQueryType) -> Unit,
+ resultText: String?,
+ modifier: Modifier = Modifier
+) {
+ val types = NCICQueryType.entries
+ var typeIx by rememberSaveable { mutableIntStateOf(0) }
+ var ori by rememberSaveable { mutableStateOf("AAA123456") }
+ var ucn by rememberSaveable { mutableStateOf("AAA123456250320ABCDEF") }
+
Column(
- modifier = modifier
- .fillMaxSize()
- .padding(16.dp)
+ modifier = modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
+ Text("NCIC / III", style = MaterialTheme.typography.headlineSmall)
Text(
- text = "NCIC Query",
- style = MaterialTheme.typography.headlineMedium
+ "Live queries require CJIS connectivity and approval. This screen exercises ORI/UCN validation and audit logging.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
+ Text(
+ "Operator: $operatorId",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ OutlinedTextField(
+ ori,
+ { ori = it.uppercase() },
+ label = { Text("ORI (9 chars)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ ucn,
+ { ucn = it },
+ label = { Text("UCN (15+ chars)") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ RowTypeSelector(types[typeIx].name) {
+ typeIx = (typeIx + 1) % types.size
+ }
+ Button(
+ onClick = { onRunQuery(ori.trim(), ucn.trim(), types[typeIx]) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Execute (simulated)")
+ }
+ resultText?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
}
}
+@Composable
+private fun RowTypeSelector(label: String, onChange: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Query type: $label", style = MaterialTheme.typography.bodyMedium)
+ TextButton(onClick = onChange) { Text("Change") }
+ }
+}
diff --git a/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NcicViewModel.kt b/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NcicViewModel.kt
new file mode 100644
index 0000000..6784756
--- /dev/null
+++ b/modules/ncic/src/main/java/com/smoa/modules/ncic/ui/NcicViewModel.kt
@@ -0,0 +1,40 @@
+package com.smoa.modules.ncic.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.modules.ncic.domain.NCICQuery
+import com.smoa.modules.ncic.domain.NCICQueryType
+import com.smoa.modules.ncic.domain.NCICService
+import dagger.hilt.android.lifecycle.HiltViewModel
+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 NcicViewModel @Inject constructor(
+ private val ncicService: NCICService
+) : ViewModel() {
+
+ private val _result = MutableStateFlow(null)
+ val result: StateFlow = _result.asStateFlow()
+
+ fun runQuery(ori: String, ucn: String, type: NCICQueryType, operatorId: String) {
+ viewModelScope.launch {
+ val q = NCICQuery(
+ ori = ori.uppercase(),
+ ucn = ucn,
+ queryType = type,
+ searchCriteria = mapOf("demo" to "true"),
+ operatorId = operatorId
+ )
+ ncicService.executeQuery(q).fold(
+ onSuccess = { r ->
+ _result.value = "${r.responseCode.name}: ${r.message}"
+ },
+ onFailure = { e -> _result.value = e.message ?: "Query failed" }
+ )
+ }
+ }
+}
diff --git a/modules/orders/build.gradle.kts b/modules/orders/build.gradle.kts
index 6bfe72d..f85d6c0 100644
--- a/modules/orders/build.gradle.kts
+++ b/modules/orders/build.gradle.kts
@@ -50,6 +50,10 @@ dependencies {
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.androidxLifecycleRuntimeCompose)
+ implementation(Dependencies.hiltNavigationCompose)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
@@ -63,4 +67,8 @@ dependencies {
implementation(Dependencies.coroutinesCore)
implementation(Dependencies.coroutinesAndroid)
+
+ testImplementation(Dependencies.junit)
+ testImplementation(Dependencies.coroutinesTest)
+ testImplementation(Dependencies.mockk)
}
diff --git a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderDetailScreen.kt b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderDetailScreen.kt
index 2493e09..7572474 100644
--- a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderDetailScreen.kt
+++ b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderDetailScreen.kt
@@ -1,33 +1,50 @@
package com.smoa.modules.orders.ui
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.smoa.modules.orders.domain.Order
-/**
- * Order detail screen for viewing individual order.
- */
@Composable
fun OrderDetailScreen(
- orderId: String,
+ order: Order,
+ onAdvanceWorkflow: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
- .padding(16.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
+ Text(order.title, style = MaterialTheme.typography.headlineSmall)
Text(
- text = "Order Details",
- style = MaterialTheme.typography.headlineMedium,
- modifier = Modifier.padding(bottom = 16.dp)
+ "Type: ${order.orderType.name} Status: ${order.status.name}",
+ style = MaterialTheme.typography.bodyMedium
)
- // Order detail UI will be implemented here
+ Text("Content", style = MaterialTheme.typography.labelLarge)
+ Text(order.content, style = MaterialTheme.typography.bodyMedium)
+ Text(
+ "Jurisdiction: ${order.metadata.jurisdiction} Case: ${order.metadata.caseNumber ?: "—"}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Button(
+ onClick = onAdvanceWorkflow,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Advance workflow status")
+ }
}
}
-
diff --git a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderListScreen.kt b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderListScreen.kt
index 3aa5fc7..2ab52be 100644
--- a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderListScreen.kt
+++ b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrderListScreen.kt
@@ -1,32 +1,75 @@
package com.smoa.modules.orders.ui
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.smoa.modules.orders.domain.Order
-/**
- * Order list screen displaying all orders.
- */
@Composable
fun OrderListScreen(
+ orders: List,
+ onOrderClick: (Order) -> Unit,
modifier: Modifier = Modifier
) {
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(16.dp)
+ if (orders.isEmpty()) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "No orders yet",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = "Use the + action to create a sample order for demonstration.",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+ return
+ }
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- Text(
- text = "Orders",
- style = MaterialTheme.typography.headlineMedium,
- modifier = Modifier.padding(bottom = 16.dp)
- )
- // Order list UI will be implemented here
+ items(orders, key = { it.orderId }) { order ->
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onOrderClick(order) },
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Text(order.title, style = MaterialTheme.typography.titleMedium)
+ Text(
+ "${order.orderType.name} · ${order.status.name}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ "Issued by ${order.issuedBy}",
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
+ }
+ }
}
}
-
diff --git a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersModule.kt b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersModule.kt
index dbb226d..278e2f3 100644
--- a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersModule.kt
+++ b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersModule.kt
@@ -1,31 +1,97 @@
package com.smoa.modules.orders.ui
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.smoa.modules.orders.domain.Order
+import kotlinx.coroutines.launch
/**
- * Orders module - Digital orders management system.
+ * Orders hub: list and detail backed by [OrderService] / Room.
*/
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OrdersModule(
- modifier: Modifier = Modifier
+ userId: String,
+ modifier: Modifier = Modifier,
+ viewModel: OrdersViewModel = hiltViewModel()
) {
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(16.dp)
- ) {
- Text(
- text = "Orders Management",
- style = MaterialTheme.typography.headlineMedium
- )
- // Orders management UI will be implemented here
+ val orders by viewModel.orders.collectAsStateWithLifecycle()
+ var selectedId: String? by remember { mutableStateOf(null) }
+ val selected = selectedId?.let { id -> orders.firstOrNull { it.orderId == id } }
+ val snack = remember { SnackbarHostState() }
+ val scope = rememberCoroutineScope()
+
+ Scaffold(
+ modifier = modifier,
+ snackbarHost = { SnackbarHost(snack) },
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(if (selected == null) "Orders" else "Order detail")
+ },
+ navigationIcon = {
+ if (selected != null) {
+ IconButton(onClick = { selectedId = null }) {
+ Icon(Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ if (selectedId == null) {
+ FloatingActionButton(
+ onClick = {
+ viewModel.createSampleOrder(userId) { err ->
+ scope.launch {
+ snack.showSnackbar(err ?: "Sample order created")
+ }
+ }
+ }
+ ) {
+ Icon(Icons.Default.Add, contentDescription = "Add sample order")
+ }
+ }
+ }
+ ) { padding ->
+ when (val o = selected) {
+ null -> OrderListScreen(
+ orders = orders,
+ onOrderClick = { selectedId = it.orderId },
+ modifier = Modifier.padding(padding)
+ )
+ else -> OrderDetailScreen(
+ order = o,
+ onAdvanceWorkflow = {
+ viewModel.advanceStatus(o.orderId, userId) { err ->
+ scope.launch {
+ snack.showSnackbar(err ?: "Status updated")
+ }
+ }
+ },
+ modifier = Modifier.padding(padding)
+ )
+ }
}
}
-
diff --git a/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersViewModel.kt b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersViewModel.kt
new file mode 100644
index 0000000..33e43c4
--- /dev/null
+++ b/modules/orders/src/main/java/com/smoa/modules/orders/ui/OrdersViewModel.kt
@@ -0,0 +1,77 @@
+package com.smoa.modules.orders.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.core.common.Result
+import com.smoa.modules.orders.domain.Order
+import com.smoa.modules.orders.domain.OrderMetadata
+import com.smoa.modules.orders.domain.OrderService
+import com.smoa.modules.orders.domain.OrderStatus
+import com.smoa.modules.orders.domain.OrderType
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import java.util.Date
+import javax.inject.Inject
+
+@HiltViewModel
+class OrdersViewModel @Inject constructor(
+ private val orderService: OrderService
+) : ViewModel() {
+
+ val orders: StateFlow> = orderService.getAllOrders()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
+
+ fun createSampleOrder(userId: String, onResult: (String?) -> Unit) {
+ viewModelScope.launch {
+ when (
+ val r: Result = orderService.createOrder(
+ orderType = OrderType.ADMINISTRATIVE,
+ title = "Sample order",
+ content = "Demonstration order created from SMOA.",
+ issuedBy = userId,
+ issuedTo = null,
+ effectiveDate = Date(),
+ expirationDate = null,
+ metadata = OrderMetadata(
+ classification = null,
+ jurisdiction = "DEMO",
+ caseNumber = null,
+ keywords = listOf("sample")
+ )
+ )
+ ) {
+ is Result.Success -> onResult(null)
+ is Result.Error -> onResult(r.exception.message ?: "Error")
+ Result.Loading -> onResult(null)
+ }
+ }
+ }
+
+ fun advanceStatus(orderId: String, userId: String, onResult: (String?) -> Unit) {
+ viewModelScope.launch {
+ val order = orders.value.firstOrNull { it.orderId == orderId } ?: run {
+ onResult("Order not found")
+ return@launch
+ }
+ val next = when (order.status) {
+ OrderStatus.DRAFT -> OrderStatus.PENDING_APPROVAL
+ OrderStatus.PENDING_APPROVAL -> OrderStatus.APPROVED
+ OrderStatus.APPROVED -> OrderStatus.ISSUED
+ OrderStatus.ISSUED -> OrderStatus.EXECUTED
+ else -> null
+ }
+ if (next == null) {
+ onResult("No further workflow step")
+ return@launch
+ }
+ when (val r = orderService.updateOrderStatus(orderId, next, userId)) {
+ is Result.Success -> onResult(null)
+ is Result.Error -> onResult(r.exception.message ?: "Error")
+ Result.Loading -> onResult(null)
+ }
+ }
+ }
+}
diff --git a/modules/orders/src/test/java/com/smoa/modules/orders/domain/OrderServiceTest.kt b/modules/orders/src/test/java/com/smoa/modules/orders/domain/OrderServiceTest.kt
new file mode 100644
index 0000000..68312a3
--- /dev/null
+++ b/modules/orders/src/test/java/com/smoa/modules/orders/domain/OrderServiceTest.kt
@@ -0,0 +1,50 @@
+package com.smoa.modules.orders.domain
+
+import com.smoa.core.common.Result
+import com.smoa.core.security.AuditLogger
+import com.smoa.core.security.AuditEventType
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.match
+import io.mockk.mockk
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Date
+
+class OrderServiceTest {
+
+ private val repository = mockk(relaxed = true)
+ private val auditLogger = mockk(relaxed = true)
+ private val service = OrderService(repository, auditLogger)
+
+ @Test
+ fun createOrder_insertsAndAudits() = runTest {
+ coEvery { repository.insertOrder(any()) } returns Unit
+ every { repository.getAllOrders() } returns flowOf(emptyList())
+
+ val r = service.createOrder(
+ orderType = OrderType.ADMINISTRATIVE,
+ title = "T",
+ content = "C",
+ issuedBy = "u1",
+ issuedTo = null,
+ effectiveDate = Date(),
+ expirationDate = null,
+ metadata = OrderMetadata(null, "J", null, emptyList(), emptyList())
+ )
+
+ assertTrue(r is Result.Success)
+ coVerify(exactly = 1) { repository.insertOrder(any()) }
+ coVerify {
+ auditLogger.logEvent(
+ AuditEventType.POLICY_UPDATE,
+ userId = "u1",
+ module = "orders",
+ details = match { it != null && it.startsWith("Order created:") }
+ )
+ }
+ }
+}
diff --git a/modules/reports/build.gradle.kts b/modules/reports/build.gradle.kts
index 5b8ecb0..44c881e 100644
--- a/modules/reports/build.gradle.kts
+++ b/modules/reports/build.gradle.kts
@@ -49,6 +49,10 @@ dependencies {
implementation(Dependencies.composeMaterial3)
implementation(Dependencies.androidxCoreKtx)
implementation(Dependencies.androidxLifecycleRuntimeKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelKtx)
+ implementation(Dependencies.androidxLifecycleViewmodelCompose)
+ implementation(Dependencies.androidxLifecycleRuntimeCompose)
+ implementation(Dependencies.hiltNavigationCompose)
implementation(Dependencies.hiltAndroid)
kapt(Dependencies.hiltAndroidCompiler)
diff --git a/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportGenerationScreen.kt b/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportGenerationScreen.kt
index 1506e0a..c070616 100644
--- a/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportGenerationScreen.kt
+++ b/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportGenerationScreen.kt
@@ -1,25 +1,103 @@
package com.smoa.modules.reports.ui
+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.material3.Button
+import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.smoa.modules.reports.domain.ReportFormat
+import com.smoa.modules.reports.domain.ReportType
@Composable
-fun ReportGenerationScreen(modifier: Modifier = Modifier) {
+fun ReportGenerationScreen(
+ userId: String,
+ modifier: Modifier = Modifier,
+ viewModel: ReportsViewModel = hiltViewModel()
+) {
+ val types = ReportType.entries
+ val formats = ReportFormat.entries
+ var typeIx by rememberSaveable { mutableIntStateOf(0) }
+ var formatIx by rememberSaveable { mutableIntStateOf(0) }
+ var title by rememberSaveable { mutableStateOf("Operational summary") }
+ var sign by rememberSaveable { mutableStateOf(true) }
+ val msg by viewModel.lastMessage.collectAsStateWithLifecycle()
+ val len by viewModel.lastByteLength.collectAsStateWithLifecycle()
+
Column(
modifier = modifier
.fillMaxSize()
- .padding(16.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
+ Text("Reports", style = MaterialTheme.typography.headlineSmall)
Text(
- text = "Generate Report",
- style = MaterialTheme.typography.headlineMedium
+ "Generate multi-format reports (JSON/XML/CSV/PDF/Excel per generator).",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Type: ${types[typeIx].name}")
+ TextButton(onClick = { typeIx = (typeIx + 1) % types.size }) { Text("Change") }
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Format: ${formats[formatIx].name}")
+ TextButton(onClick = { formatIx = (formatIx + 1) % formats.size }) { Text("Change") }
+ }
+ OutlinedTextField(
+ value = title,
+ onValueChange = { title = it },
+ label = { Text("Title") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Checkbox(checked = sign, onCheckedChange = { sign = it })
+ Text("Include content-hash signature", style = MaterialTheme.typography.bodyMedium)
+ }
+ Button(
+ onClick = {
+ viewModel.generate(
+ reportType = types[typeIx],
+ format = formats[formatIx],
+ title = title,
+ userId = userId,
+ sign = sign
+ )
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Generate")
+ }
+ msg?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
+ len?.let { Text("Output size: $len bytes", style = MaterialTheme.typography.labelSmall) }
}
}
-
diff --git a/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportsViewModel.kt b/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportsViewModel.kt
new file mode 100644
index 0000000..8a2ca4d
--- /dev/null
+++ b/modules/reports/src/main/java/com/smoa/modules/reports/ui/ReportsViewModel.kt
@@ -0,0 +1,56 @@
+package com.smoa.modules.reports.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.smoa.modules.reports.domain.ReportFormat
+import com.smoa.modules.reports.domain.ReportService
+import com.smoa.modules.reports.domain.ReportType
+import dagger.hilt.android.lifecycle.HiltViewModel
+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 ReportsViewModel @Inject constructor(
+ private val reportService: ReportService
+) : ViewModel() {
+
+ private val _lastMessage = MutableStateFlow(null)
+ val lastMessage: StateFlow = _lastMessage.asStateFlow()
+
+ private val _lastByteLength = MutableStateFlow(null)
+ val lastByteLength: StateFlow = _lastByteLength.asStateFlow()
+
+ fun generate(
+ reportType: ReportType,
+ format: ReportFormat,
+ title: String,
+ userId: String,
+ sign: Boolean
+ ) {
+ viewModelScope.launch {
+ reportService.signReports = sign
+ val body = "SMOA report: $title\nType=${reportType.name}\nFormat=${format.name}\n"
+ .toByteArray(Charsets.UTF_8)
+ reportService.generateReport(
+ reportType = reportType,
+ format = format,
+ title = title,
+ content = body,
+ generatedBy = userId,
+ template = null
+ ).fold(
+ onSuccess = { rep ->
+ _lastByteLength.value = rep.content.size
+ _lastMessage.value = "Generated ${rep.reportId} (${rep.content.size} bytes)"
+ },
+ onFailure = { e ->
+ _lastMessage.value = e.message ?: "Failed"
+ _lastByteLength.value = null
+ }
+ )
+ }
+ }
+}