Monorepo: Gitea CI, docs, auth/sync, backend APIs, gitignore

- Add Gitea Actions workflow; point README to gitea.d-bis.org/Sankofa_Phoenix/SMOA
- Expand .gitignore for Spring H2 data, secrets, Kotlin .kotlin/, tooling
- Track docs/api/generated ReDoc bundle; refresh api docs README
- Android: network/auth/sync, UI shell, tests; backend credentials/integrity APIs
- Docs, scripts (generate-api-docs), modules and core updates

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

Some files were not shown because too many files have changed in this diff Show More