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:
@@ -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
@@ -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/
|
||||
|
||||
Vendored
+2
-1
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
"java.configuration.updateBuildConfiguration": "automatic",
|
||||
"java.compile.nullAnalysis.mode": "automatic"
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# Secure Mobile Operations Application (SMOA)
|
||||
|
||||
[](https://gitea.d-bis.org/Sankofa_Phoenix/SMOA/actions)
|
||||
|
||||
**Repository (monorepo):** [https://gitea.d-bis.org/Sankofa_Phoenix/SMOA](https://gitea.d-bis.org/Sankofa_Phoenix/SMOA) — `git clone https://gitea.d-bis.org/Sankofa_Phoenix/SMOA.git`
|
||||
|
||||
**CI:** [`.gitea/workflows/ci.yml`](.gitea/workflows/ci.yml) (Gitea Actions) — runs `./gradlew smoaVerify` on push/PR.
|
||||
|
||||
**Android Foldable Devices – Online / Offline Mission Operations**
|
||||
|
||||
> **Constitutional Status**: This repository implements **SMOA**, a Tier-1 canonical sovereign system. See [NOTICE.md](./NOTICE.md) and [CANONICAL_CONSTITUTION.md](../CANONICAL_CONSTITUTION.md) for details.
|
||||
@@ -72,9 +78,16 @@ smoa/
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Backend tests + Android debug APK (recommended):
|
||||
./gradlew smoaVerify --no-daemon
|
||||
# or: ./scripts/build-all.sh
|
||||
|
||||
# Full project build:
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
See [docs/development/BUILD.md](docs/development/BUILD.md) for details. Debug APK: `app/build/outputs/apk/debug/app-debug.apk`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android Studio Hedgehog or later
|
||||
@@ -144,4 +157,3 @@ See the `docs/` directory for comprehensive documentation:
|
||||
## License
|
||||
|
||||
Proprietary - Government Use Only
|
||||
|
||||
|
||||
@@ -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) |
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+30
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
+74
@@ -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)
|
||||
}
|
||||
}
|
||||
+75
@@ -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" })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & 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 & 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
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ""
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
Reference in New Issue
Block a user