Feature modules UI + RBAC; credential pull hydration; compliance doc
- Extend RBAC modules/permissions; Home + drawer + Nav routes for ATF/NCIC/military/judicial/intelligence - Wire Orders/Evidence/Reports/ATF/NCIC/Military/Judicial/Intelligence Compose flows with Hilt ViewModels - Evidence: custody chain merge in repository; list/detail UI; fix transfer entity evidenceId - SyncService: hydrate credential payloads after list pull; CredentialCacheSyncPort API + tests - CredentialCacheMerger hydrateMissingPayloads; CredentialsViewModel refresh/hydrate split - OrderServiceTest; SyncService + CredentialCacheMerger tests; NCIC operator display - docs: OUTSTANDING-PRODUCTION-AND-COMPLIANCE-WORK.md; GAPS updates; README implementation status - Misc doc/build updates (OpenAPI test, compliance matrix, etc.) Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
<string name="module_communications">Unit Communications</string>
|
||||
<string name="module_meetings">Secure Meetings</string>
|
||||
<string name="module_browser">Controlled Browser</string>
|
||||
<string name="module_atf">ATF forms</string>
|
||||
<string name="module_ncic">NCIC / III</string>
|
||||
<string name="module_military">Military operations</string>
|
||||
<string name="module_judicial">Judicial</string>
|
||||
<string name="module_intelligence">Intelligence (MLS)</string>
|
||||
|
||||
<!-- Classification / policy (replace with org-specific marking per policy) -->
|
||||
<string name="classification_watermark_primary">OFFICIAL USE ONLY</string>
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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<Permission> {
|
||||
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<Module> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,5 @@ dependencies {
|
||||
|
||||
// Testing
|
||||
testImplementation(Dependencies.junit)
|
||||
testImplementation(Dependencies.mockWebServer)
|
||||
}
|
||||
|
||||
+78
@@ -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<String> {
|
||||
return try {
|
||||
val holder = JcaX509CertificateHolder(certificate)
|
||||
val ext = holder.getExtension(Extension.cRLDistributionPoints) ?: return emptyList()
|
||||
val crlDp = CRLDistPoint.getInstance(ext.parsedValue)
|
||||
val urls = mutableListOf<String>()
|
||||
for (dp in crlDp.distributionPoints) {
|
||||
collectHttpUrlsFromDistributionPoint(dp, urls)
|
||||
}
|
||||
urls.distinct()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun collectHttpUrlsFromDistributionPoint(dp: DistributionPoint, out: MutableList<String>) {
|
||||
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.
|
||||
*/
|
||||
|
||||
+39
-4
@@ -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)
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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<ByteArray>)
|
||||
|
||||
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<ByteArray>
|
||||
) = Unit
|
||||
|
||||
override suspend fun upsertAfterCredentialSync(data: Any, serverTimestamp: Long, itemId: String) = Unit
|
||||
|
||||
override suspend fun removeCredential(credentialId: String) = Unit
|
||||
|
||||
@@ -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<PullAPI>()
|
||||
val cache = mockk<CredentialCacheSyncPort>()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+114
-131
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String?>(null)
|
||||
val message: StateFlow<String?> = _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" }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -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<ByteArray>
|
||||
) {
|
||||
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) {
|
||||
|
||||
+5
-17
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -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<CredentialCacheEntity>()
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -42,6 +42,9 @@ interface CustodyTransferDao {
|
||||
@Query("SELECT * FROM custody_transfers WHERE evidenceId = :evidenceId ORDER BY timestamp ASC")
|
||||
fun getChainOfCustody(evidenceId: String): Flow<List<CustodyTransferEntity>>
|
||||
|
||||
@Query("SELECT * FROM custody_transfers ORDER BY timestamp ASC")
|
||||
fun observeAllTransfers(): Flow<List<CustodyTransferEntity>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertTransfer(transfer: CustodyTransferEntity)
|
||||
|
||||
|
||||
+16
-9
@@ -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<List<Evidence>> {
|
||||
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<List<CustodyTransfer>> {
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Evidence>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<List<Evidence>> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+60
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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<Compartment> = compartments.values.toList()
|
||||
|
||||
/**
|
||||
* Check if user has access to compartment.
|
||||
|
||||
+20
@@ -36,6 +36,26 @@ class IntelligenceService @Inject constructor(
|
||||
return compartmentManager.hasAccess(userId, compartmentId) &&
|
||||
compartmentManager.hasNeedToKnow(userId, compartmentId)
|
||||
}
|
||||
|
||||
fun listRegisteredCompartments(): List<Compartment> =
|
||||
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.
|
||||
|
||||
+77
@@ -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<List<String>>(emptyList())
|
||||
val lines: StateFlow<List<String>> = _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") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String?>(null)
|
||||
val summary: StateFlow<String?> = _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" }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String?>(null)
|
||||
val credentialSummary: StateFlow<String?> = _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" }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>(null)
|
||||
val result: StateFlow<String?> = _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" }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Order>,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<List<Order>> = orderService.getAllOrders()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
fun createSampleOrder(userId: String, onResult: (String?) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
when (
|
||||
val r: Result<Order> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<OrderRepository>(relaxed = true)
|
||||
private val auditLogger = mockk<AuditLogger>(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:") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+83
-5
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String?>(null)
|
||||
val lastMessage: StateFlow<String?> = _lastMessage.asStateFlow()
|
||||
|
||||
private val _lastByteLength = MutableStateFlow<Int?>(null)
|
||||
val lastByteLength: StateFlow<Int?> = _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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user