oh-my-agent-commandcode 0.0.0-stage → 20.38.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/filter-test-output.sh +52 -0
- package/mods/oma.ts +347 -0
- package/package.json +30 -4
- package/skills/_shared/conditional/experiment-ledger.md +28 -0
- package/skills/_shared/conditional/exploration-loop.md +22 -0
- package/skills/_shared/conditional/quality-score.md +33 -0
- package/skills/_shared/core/anti-ai-prose.md +191 -0
- package/skills/_shared/core/api-contracts/README.md +28 -0
- package/skills/_shared/core/api-contracts/template.md +90 -0
- package/skills/_shared/core/clarification-protocol.md +23 -0
- package/skills/_shared/core/code-intelligence.md +46 -0
- package/skills/_shared/core/collaboration-policy.md +28 -0
- package/skills/_shared/core/common-checklist.md +28 -0
- package/skills/_shared/core/context-budget.md +22 -0
- package/skills/_shared/core/context-loading.md +34 -0
- package/skills/_shared/core/difficulty-guide.md +26 -0
- package/skills/_shared/core/execution-policy.md +31 -0
- package/skills/_shared/core/lessons-learned.md +33 -0
- package/skills/_shared/core/prompt-structure.md +18 -0
- package/skills/_shared/core/quality-principles.md +11 -0
- package/skills/_shared/core/session-metrics.md +35 -0
- package/skills/_shared/core/skill-routing.md +39 -0
- package/skills/_shared/core/test-approach.md +54 -0
- package/skills/_shared/core/vendor-detection.md +52 -0
- package/skills/_shared/runtime/memory-protocol.md +118 -0
- package/skills/_shared/runtime/result-contract.md +64 -0
- package/skills/_shared/ui-interaction-quality.md +133 -0
- package/skills/oma-academic-writing/SKILL.md +181 -0
- package/skills/oma-academic-writing/resources/abstract-guide.md +51 -0
- package/skills/oma-academic-writing/resources/academic-verb-tiers.md +42 -0
- package/skills/oma-academic-writing/resources/anti-ai-checklist.md +52 -0
- package/skills/oma-academic-writing/resources/hedging-guide.md +132 -0
- package/skills/oma-academic-writing/resources/sentence-structure-reference.md +151 -0
- package/skills/oma-architecture/SKILL.md +203 -0
- package/skills/oma-architecture/resources/api-evolution.md +41 -0
- package/skills/oma-architecture/resources/checklist.md +42 -0
- package/skills/oma-architecture/resources/execution-protocol.md +115 -0
- package/skills/oma-architecture/resources/methodology-selection.md +92 -0
- package/skills/oma-architecture/resources/migration-patterns.md +28 -0
- package/skills/oma-architecture/resources/output-templates.md +111 -0
- package/skills/oma-architecture/resources/stakeholder-synthesis.md +80 -0
- package/skills/oma-backend/SKILL.md +202 -0
- package/skills/oma-backend/resources/checklist.md +45 -0
- package/skills/oma-backend/resources/error-playbook.md +97 -0
- package/skills/oma-backend/resources/execution-protocol.md +39 -0
- package/skills/oma-backend/resources/orm-reference.md +61 -0
- package/skills/oma-backend/variants/node/api-template.ts +315 -0
- package/skills/oma-backend/variants/node/snippets.md +475 -0
- package/skills/oma-backend/variants/node/stack.yaml +21 -0
- package/skills/oma-backend/variants/node/tech-stack.md +33 -0
- package/skills/oma-backend/variants/python/api-template.py +378 -0
- package/skills/oma-backend/variants/python/snippets.md +199 -0
- package/skills/oma-backend/variants/python/stack.yaml +18 -0
- package/skills/oma-backend/variants/python/tech-stack.md +45 -0
- package/skills/oma-backend/variants/rust/api-template.rs +635 -0
- package/skills/oma-backend/variants/rust/snippets.md +471 -0
- package/skills/oma-backend/variants/rust/stack.yaml +20 -0
- package/skills/oma-backend/variants/rust/tech-stack.md +36 -0
- package/skills/oma-backend/variants/stack.schema.json +73 -0
- package/skills/oma-brainstorm/SKILL.md +166 -0
- package/skills/oma-brainstorm/resources/triz-lite.md +117 -0
- package/skills/oma-coordination/SKILL.md +171 -0
- package/skills/oma-db/SKILL.md +201 -0
- package/skills/oma-db/resources/anti-patterns.md +177 -0
- package/skills/oma-db/resources/checklist.md +67 -0
- package/skills/oma-db/resources/document-templates.md +107 -0
- package/skills/oma-db/resources/error-playbook.md +50 -0
- package/skills/oma-db/resources/examples.md +170 -0
- package/skills/oma-db/resources/execution-protocol.md +127 -0
- package/skills/oma-db/resources/iso-controls.md +98 -0
- package/skills/oma-db/resources/migration-playbook.md +93 -0
- package/skills/oma-db/resources/query-tuning.md +57 -0
- package/skills/oma-db/resources/vector-db.md +159 -0
- package/skills/oma-debug/SKILL.md +146 -0
- package/skills/oma-debug/resources/bug-report-template.md +327 -0
- package/skills/oma-debug/resources/checklist.md +31 -0
- package/skills/oma-debug/resources/common-patterns.md +734 -0
- package/skills/oma-debug/resources/debugging-checklist.md +364 -0
- package/skills/oma-debug/resources/error-playbook.md +94 -0
- package/skills/oma-debug/resources/execution-protocol.md +40 -0
- package/skills/oma-design/SKILL.md +135 -0
- package/skills/oma-design/resources/asset-strategy.md +34 -0
- package/skills/oma-design/resources/checklist.md +63 -0
- package/skills/oma-design/resources/data-grid.md +48 -0
- package/skills/oma-design/resources/design-md-spec.md +107 -0
- package/skills/oma-design/resources/design-tokens.md +53 -0
- package/skills/oma-design/resources/error-playbook.md +18 -0
- package/skills/oma-design/resources/execution-protocol.md +95 -0
- package/skills/oma-design/resources/external-design-skills.md +130 -0
- package/skills/oma-design/resources/getdesign-fetcher.md +104 -0
- package/skills/oma-design/resources/product-interface-review.md +59 -0
- package/skills/oma-design/resources/redesign-protocol.md +52 -0
- package/skills/oma-design/resources/stitch-integration.md +244 -0
- package/skills/oma-dev-workflow/SKILL.md +375 -0
- package/skills/oma-dev-workflow/resources/affected-checks.py +58 -0
- package/skills/oma-dev-workflow/resources/api-workflows.md +58 -0
- package/skills/oma-dev-workflow/resources/database-patterns.md +107 -0
- package/skills/oma-dev-workflow/resources/i18n-patterns.md +63 -0
- package/skills/oma-dev-workflow/resources/release-coordination.md +240 -0
- package/skills/oma-dev-workflow/resources/tooling-profiles.md +217 -0
- package/skills/oma-dev-workflow/resources/troubleshooting.md +78 -0
- package/skills/oma-dev-workflow/resources/validation-pipeline.md +246 -0
- package/skills/oma-docs/SKILL.md +77 -0
- package/skills/oma-docs/resources/commands.md +76 -0
- package/skills/oma-explanation/SKILL.md +212 -0
- package/skills/oma-explanation/resources/document-structure.md +115 -0
- package/skills/oma-explanation/resources/draft-format.md +199 -0
- package/skills/oma-explanation/resources/html-contract.md +102 -0
- package/skills/oma-frontend/SKILL.md +227 -0
- package/skills/oma-frontend/resources/angular-rules.md +110 -0
- package/skills/oma-frontend/resources/checklist.md +76 -0
- package/skills/oma-frontend/resources/error-playbook.md +125 -0
- package/skills/oma-frontend/resources/execution-protocol.md +46 -0
- package/skills/oma-frontend/resources/snippets.md +354 -0
- package/skills/oma-frontend/resources/specialized-visual-implementation.md +193 -0
- package/skills/oma-frontend/resources/tailwind-rules.md +83 -0
- package/skills/oma-frontend/resources/tech-stack.md +156 -0
- package/skills/oma-hwp/SKILL.md +166 -0
- package/skills/oma-hwp/config/hwp-config.yaml +35 -0
- package/skills/oma-hwp/resources/balanced-tables.ts +39 -0
- package/skills/oma-hwp/resources/execution-protocol.md +145 -0
- package/skills/oma-hwp/resources/flatten-tables.ts +101 -0
- package/skills/oma-hwp/resources/package.json +13 -0
- package/skills/oma-hwp/resources/troubleshooting.md +70 -0
- package/skills/oma-hwp/resources/tsconfig.json +10 -0
- package/skills/oma-hwp/resources/turndown-plugin-gfm.d.ts +9 -0
- package/skills/oma-image/SKILL.md +142 -0
- package/skills/oma-image/config/image-config.yaml +70 -0
- package/skills/oma-image/resources/checklist.md +18 -0
- package/skills/oma-image/resources/execution-protocol.md +117 -0
- package/skills/oma-image/resources/invocation.md +105 -0
- package/skills/oma-image/resources/prompt-tips.md +71 -0
- package/skills/oma-image/resources/vendor-matrix.md +90 -0
- package/skills/oma-market/SKILL.md +142 -0
- package/skills/oma-market/resources/checklist.md +12 -0
- package/skills/oma-market/resources/error-playbook.md +12 -0
- package/skills/oma-market/resources/execution-protocol.md +60 -0
- package/skills/oma-market/resources/frameworks/pestel.md +101 -0
- package/skills/oma-market/resources/frameworks/porters-5f.md +90 -0
- package/skills/oma-market/resources/frameworks/swot.md +61 -0
- package/skills/oma-market/resources/intent-rules.md +35 -0
- package/skills/oma-market/resources/output-laws.md +53 -0
- package/skills/oma-mobile/SKILL.md +168 -0
- package/skills/oma-mobile/resources/checklist.md +77 -0
- package/skills/oma-mobile/resources/error-playbook.md +198 -0
- package/skills/oma-mobile/resources/execution-protocol.md +47 -0
- package/skills/oma-mobile/resources/screen-template.dart +331 -0
- package/skills/oma-mobile/resources/screen-template.swift +239 -0
- package/skills/oma-mobile/resources/screen-template.tsx +179 -0
- package/skills/oma-mobile/resources/tech-stack.md +69 -0
- package/skills/oma-mobile/variants/README.md +45 -0
- package/skills/oma-mobile/variants/flutter/api-template.dart +461 -0
- package/skills/oma-mobile/variants/flutter/snippets.md +1353 -0
- package/skills/oma-mobile/variants/flutter/stack.yaml +21 -0
- package/skills/oma-mobile/variants/flutter/tech-stack.md +201 -0
- package/skills/oma-mobile/variants/kotlin-android/api-template.kt +182 -0
- package/skills/oma-mobile/variants/kotlin-android/snippets.md +871 -0
- package/skills/oma-mobile/variants/kotlin-android/stack.yaml +32 -0
- package/skills/oma-mobile/variants/kotlin-android/tech-stack.md +159 -0
- package/skills/oma-mobile/variants/react-native/api-template.ts +531 -0
- package/skills/oma-mobile/variants/react-native/snippets.md +1014 -0
- package/skills/oma-mobile/variants/react-native/stack.yaml +22 -0
- package/skills/oma-mobile/variants/react-native/tech-stack.md +192 -0
- package/skills/oma-mobile/variants/stack.schema.json +68 -0
- package/skills/oma-mobile/variants/swift-ios/api-template.swift +165 -0
- package/skills/oma-mobile/variants/swift-ios/snippets.md +1028 -0
- package/skills/oma-mobile/variants/swift-ios/stack.yaml +25 -0
- package/skills/oma-mobile/variants/swift-ios/tech-stack.md +174 -0
- package/skills/oma-observability/SKILL.md +225 -0
- package/skills/oma-observability/resources/anti-patterns.md +555 -0
- package/skills/oma-observability/resources/boundaries/cross-application.md +348 -0
- package/skills/oma-observability/resources/boundaries/multi-tenant.md +290 -0
- package/skills/oma-observability/resources/boundaries/release.md +372 -0
- package/skills/oma-observability/resources/boundaries/slo.md +225 -0
- package/skills/oma-observability/resources/checklist.md +123 -0
- package/skills/oma-observability/resources/examples.md +448 -0
- package/skills/oma-observability/resources/execution-protocol.md +166 -0
- package/skills/oma-observability/resources/incident-forensics.md +348 -0
- package/skills/oma-observability/resources/intent-rules.md +307 -0
- package/skills/oma-observability/resources/layers/L3-network.md +281 -0
- package/skills/oma-observability/resources/layers/L4-transport.md +299 -0
- package/skills/oma-observability/resources/layers/L7-application/crash-analytics.md +359 -0
- package/skills/oma-observability/resources/layers/L7-application/mobile-rum.md +278 -0
- package/skills/oma-observability/resources/layers/L7-application/waf.md +246 -0
- package/skills/oma-observability/resources/layers/L7-application/web-rum.md +350 -0
- package/skills/oma-observability/resources/layers/mesh.md +368 -0
- package/skills/oma-observability/resources/matrix.md +177 -0
- package/skills/oma-observability/resources/meta-observability.md +605 -0
- package/skills/oma-observability/resources/observability-as-code.md +666 -0
- package/skills/oma-observability/resources/signals/audit.md +310 -0
- package/skills/oma-observability/resources/signals/cost.md +303 -0
- package/skills/oma-observability/resources/signals/logs.md +263 -0
- package/skills/oma-observability/resources/signals/metrics.md +478 -0
- package/skills/oma-observability/resources/signals/privacy.md +351 -0
- package/skills/oma-observability/resources/signals/profiles.md +245 -0
- package/skills/oma-observability/resources/signals/traces.md +360 -0
- package/skills/oma-observability/resources/standards.md +214 -0
- package/skills/oma-observability/resources/transport/collector-topology.md +308 -0
- package/skills/oma-observability/resources/transport/otlp-grpc-vs-http.md +256 -0
- package/skills/oma-observability/resources/transport/sampling-recipes.md +297 -0
- package/skills/oma-observability/resources/transport/udp-statsd-mtu.md +167 -0
- package/skills/oma-observability/resources/vendor-categories.md +352 -0
- package/skills/oma-orchestration/SKILL.md +302 -0
- package/skills/oma-orchestration/config/cli-config.yaml +92 -0
- package/skills/oma-orchestration/resources/memory-schema.md +232 -0
- package/skills/oma-orchestration/resources/subagent-prompt-template.md +30 -0
- package/skills/oma-orchestration/scripts/parallel-run.sh +6 -0
- package/skills/oma-orchestration/scripts/spawn-agent.sh +5 -0
- package/skills/oma-orchestration/scripts/verify.sh +20 -0
- package/skills/oma-orchestration/templates/backend-task.md +19 -0
- package/skills/oma-orchestration/templates/debug-task.md +16 -0
- package/skills/oma-orchestration/templates/frontend-task.md +18 -0
- package/skills/oma-orchestration/templates/mobile-task.md +18 -0
- package/skills/oma-orchestration/templates/qa-task.md +16 -0
- package/skills/oma-orchestration/templates/tasks-example.yaml +15 -0
- package/skills/oma-pdf/SKILL.md +162 -0
- package/skills/oma-pdf/config/pdf-config.yaml +27 -0
- package/skills/oma-pdf/resources/execution-protocol.md +127 -0
- package/skills/oma-pm/SKILL.md +157 -0
- package/skills/oma-pm/resources/error-playbook.md +75 -0
- package/skills/oma-pm/resources/examples.md +439 -0
- package/skills/oma-pm/resources/execution-protocol.md +62 -0
- package/skills/oma-pm/resources/iso-planning.md +110 -0
- package/skills/oma-pm/resources/plan-phase-protocol.md +66 -0
- package/skills/oma-pm/resources/task-template.json +86 -0
- package/skills/oma-qa/SKILL.md +141 -0
- package/skills/oma-qa/resources/checklist.md +339 -0
- package/skills/oma-qa/resources/error-playbook.md +98 -0
- package/skills/oma-qa/resources/execution-protocol.md +169 -0
- package/skills/oma-qa/resources/iso-quality.md +82 -0
- package/skills/oma-qa/resources/verify-ship-protocol.md +116 -0
- package/skills/oma-recap/SKILL.md +245 -0
- package/skills/oma-recap/resources/output-formats.md +83 -0
- package/skills/oma-refactor/SKILL.md +184 -0
- package/skills/oma-refactor/resources/definition.md +108 -0
- package/skills/oma-refactor/resources/governance.md +33 -0
- package/skills/oma-refactor/resources/hotspots.py +44 -0
- package/skills/oma-refactor/resources/measurement.md +93 -0
- package/skills/oma-scholar/SKILL.md +276 -0
- package/skills/oma-scholar/config/scholar-config.yaml +79 -0
- package/skills/oma-scholar/resources/api-endpoints.md +174 -0
- package/skills/oma-scholar/resources/checklist.md +92 -0
- package/skills/oma-scholar/resources/execution-protocol.md +224 -0
- package/skills/oma-scholar/resources/fallback-providers.md +143 -0
- package/skills/oma-scholar/resources/setup-openalex.md +85 -0
- package/skills/oma-scholar/resources/sidecar-spec.md +341 -0
- package/skills/oma-scholar/resources/upstream-spec-cache.md +972 -0
- package/skills/oma-scm/SKILL.md +103 -0
- package/skills/oma-scm/resources/cm-operations.md +56 -0
- package/skills/oma-scm/resources/codeowners-playbook.md +45 -0
- package/skills/oma-scm/resources/conventional-commits.md +108 -0
- package/skills/oma-scm/resources/merge-risk.md +76 -0
- package/skills/oma-scm/resources/onboarding-risk-signals.md +71 -0
- package/skills/oma-search/SKILL.md +125 -0
- package/skills/oma-search/resources/checklist.md +36 -0
- package/skills/oma-search/resources/error-playbook.md +66 -0
- package/skills/oma-search/resources/examples.md +80 -0
- package/skills/oma-search/resources/execution-protocol.md +86 -0
- package/skills/oma-search/resources/intent-rules.md +47 -0
- package/skills/oma-search/resources/trust-registry.md +53 -0
- package/skills/oma-security/SKILL.md +172 -0
- package/skills/oma-security/resources/artex.md +108 -0
- package/skills/oma-security/resources/ci.md +134 -0
- package/skills/oma-security/resources/cisco-source.md +108 -0
- package/skills/oma-security/resources/deepsec-config.md +45 -0
- package/skills/oma-security/resources/deepsec-matchers.md +35 -0
- package/skills/oma-security/resources/deepsec-scanning.md +45 -0
- package/skills/oma-security/resources/deepsec-setup.md +41 -0
- package/skills/oma-security/resources/findings-contract.md +408 -0
- package/skills/oma-security/resources/mcp-scanning.md +122 -0
- package/skills/oma-security/resources/skill-scanning.md +106 -0
- package/skills/oma-security/resources/validation.md +71 -0
- package/skills/oma-skill-creation/SKILL.md +84 -0
- package/skills/oma-skill-creation/resources/prompt-evaluation.md +36 -0
- package/skills/oma-skill-creation/resources/ssl-lite-template.md +108 -0
- package/skills/oma-skill-creation/resources/validation-checklist.md +101 -0
- package/skills/oma-slide/SKILL.md +277 -0
- package/skills/oma-slide/resources/animation-patterns.md +245 -0
- package/skills/oma-slide/resources/assets/deck-stage.js +514 -0
- package/skills/oma-slide/resources/assets/viewport-base.css +257 -0
- package/skills/oma-slide/resources/canva-integration.md +203 -0
- package/skills/oma-slide/resources/checklist.md +46 -0
- package/skills/oma-slide/resources/design-doctrine.md +260 -0
- package/skills/oma-slide/resources/fixed-stage.md +232 -0
- package/skills/oma-slide/resources/generation-protocol.md +393 -0
- package/skills/oma-slide/resources/native-pptx.md +84 -0
- package/skills/oma-slide/resources/selection-index.json +461 -0
- package/skills/oma-slide/resources/style-presets.LICENSE +10 -0
- package/skills/oma-slide/resources/style-presets.md +520 -0
- package/skills/oma-tf-infra/SKILL.md +201 -0
- package/skills/oma-tf-infra/resources/checklist.md +53 -0
- package/skills/oma-tf-infra/resources/cost-optimization.md +574 -0
- package/skills/oma-tf-infra/resources/error-playbook.md +68 -0
- package/skills/oma-tf-infra/resources/examples.md +38 -0
- package/skills/oma-tf-infra/resources/execution-protocol.md +129 -0
- package/skills/oma-tf-infra/resources/iso-42001-infra.md +229 -0
- package/skills/oma-tf-infra/resources/multi-cloud-examples.md +241 -0
- package/skills/oma-tf-infra/resources/policy-testing-examples.md +332 -0
- package/skills/oma-translation/SKILL.md +229 -0
- package/skills/oma-translation/resources/anti-ai-patterns.md +104 -0
- package/skills/oma-translation/resources/lang/_template.md +73 -0
- package/skills/oma-translation/resources/lang/en.md +123 -0
- package/skills/oma-translation/resources/lang/ja.md +274 -0
- package/skills/oma-translation/resources/lang/ko.md +228 -0
- package/skills/oma-translation/resources/lang/zh.md +285 -0
- package/skills/oma-translation/resources/style-skills.md +104 -0
- package/skills/oma-translation/resources/translation-rubric.md +126 -0
- package/skills/oma-video/SKILL.md +229 -0
- package/skills/oma-video/config/video-config.yaml +60 -0
- package/skills/oma-video/resources/checklist.md +26 -0
- package/skills/oma-video/resources/execution-protocol.md +122 -0
- package/skills/oma-video/resources/existing-media.md +185 -0
- package/skills/oma-video/resources/hyperframes-authoring/README.md +44 -0
- package/skills/oma-video/resources/hyperframes-authoring/demo.md +31 -0
- package/skills/oma-video/resources/hyperframes-authoring/explainer.md +10 -0
- package/skills/oma-video/resources/hyperframes-authoring/shorts.md +10 -0
- package/skills/oma-video/resources/motion-principles.md +81 -0
- package/skills/oma-video/resources/mpt/driver.py +301 -0
- package/skills/oma-video/resources/prompt-tips.md +75 -0
- package/skills/oma-video/resources/script-schema.md +81 -0
- package/skills/oma-video/resources/sound-design.md +127 -0
- package/skills/oma-video/resources/strudel/package.json +11 -0
- package/skills/oma-video/resources/strudel/page.html +87 -0
- package/skills/oma-video/resources/strudel/render.mjs +295 -0
- package/skills/oma-video/resources/vendor-matrix.md +124 -0
- package/skills/oma-voice/SKILL.md +249 -0
- package/skills/oma-voice/config/voice-config.yaml +30 -0
- package/skills/oma-voice/resources/audio-processing.md +119 -0
- package/skills/oma-voice/resources/checklist.md +47 -0
- package/skills/oma-voice/resources/execution-protocol.md +215 -0
- package/skills/oma-voice/resources/prompt-tips.md +43 -0
- package/skills/oma-voice/resources/voice-matrix.md +51 -0
- package/skills/oma-voice/resources/voicestudio.md +87 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
# Mobile Agent - Code Snippets (Kotlin Android Native)
|
|
2
|
+
|
|
3
|
+
Copy-paste ready patterns. Use these as starting points; adapt package names, module names, and versions to the specific project.
|
|
4
|
+
Always cache decoded domain models at the Repository layer — never in a Ktor plugin or an OkHttp interceptor.
|
|
5
|
+
|
|
6
|
+
Package placeholder: `com.example.app`. Generated packages: `com.example.generated.{api,model,infrastructure}`.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## §1. Gradle wiring: OpenAPI Generator (`kotlin` / `jvm-ktor`) + Ktor + OkHttp engine
|
|
11
|
+
|
|
12
|
+
```kotlin
|
|
13
|
+
// settings.gradle.kts
|
|
14
|
+
rootProject.name = "ExampleApp"
|
|
15
|
+
include(":app", ":api-client")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```kotlin
|
|
19
|
+
// build.gradle.kts (root) — pin every plugin version once, apply per module
|
|
20
|
+
plugins {
|
|
21
|
+
id("com.android.application") version "8.9.2" apply false
|
|
22
|
+
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
|
|
23
|
+
id("org.jetbrains.kotlin.jvm") version "2.1.20" apply false
|
|
24
|
+
id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
|
|
25
|
+
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" apply false
|
|
26
|
+
id("com.google.devtools.ksp") version "2.1.20-1.0.32" apply false
|
|
27
|
+
id("com.google.dagger.hilt.android") version "2.56.2" apply false
|
|
28
|
+
id("org.openapi.generator") version "7.26.0" apply false // exact pin; never a range
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```kotlin
|
|
33
|
+
// app/build.gradle.kts
|
|
34
|
+
plugins {
|
|
35
|
+
id("com.android.application")
|
|
36
|
+
id("org.jetbrains.kotlin.android")
|
|
37
|
+
id("org.jetbrains.kotlin.plugin.compose")
|
|
38
|
+
id("org.jetbrains.kotlin.plugin.serialization")
|
|
39
|
+
id("com.google.devtools.ksp")
|
|
40
|
+
id("com.google.dagger.hilt.android")
|
|
41
|
+
id("org.openapi.generator")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
android {
|
|
45
|
+
namespace = "com.example.app"
|
|
46
|
+
compileSdk = 35
|
|
47
|
+
defaultConfig {
|
|
48
|
+
applicationId = "com.example.app"
|
|
49
|
+
minSdk = 26
|
|
50
|
+
targetSdk = 35
|
|
51
|
+
buildConfigField("String", "API_BASE_URL",
|
|
52
|
+
"\"${providers.gradleProperty("apiBaseUrl").orElse("http://10.0.2.2:8000").get()}\"")
|
|
53
|
+
}
|
|
54
|
+
buildFeatures { compose = true; buildConfig = true }
|
|
55
|
+
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
|
56
|
+
kotlinOptions { jvmTarget = "17" }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dependencies {
|
|
60
|
+
implementation(project(":api-client"))
|
|
61
|
+
|
|
62
|
+
val composeBom = platform("androidx.compose:compose-bom:2025.04.01")
|
|
63
|
+
implementation(composeBom)
|
|
64
|
+
implementation("androidx.compose.ui:ui")
|
|
65
|
+
implementation("androidx.compose.material3:material3")
|
|
66
|
+
implementation("androidx.activity:activity-compose:1.10.1")
|
|
67
|
+
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0")
|
|
68
|
+
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
|
|
69
|
+
implementation("androidx.navigation:navigation-compose:2.9.0")
|
|
70
|
+
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
|
|
71
|
+
|
|
72
|
+
implementation("com.google.dagger:hilt-android:2.56.2")
|
|
73
|
+
ksp("com.google.dagger:hilt-compiler:2.56.2")
|
|
74
|
+
|
|
75
|
+
implementation("androidx.room:room-runtime:2.7.1")
|
|
76
|
+
implementation("androidx.room:room-ktx:2.7.1")
|
|
77
|
+
ksp("androidx.room:room-compiler:2.7.1")
|
|
78
|
+
|
|
79
|
+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
|
80
|
+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
|
|
81
|
+
|
|
82
|
+
// Ktor: the generated client needs core + JSON; the ENGINE is this module's choice.
|
|
83
|
+
implementation("io.ktor:ktor-client-core:3.1.3")
|
|
84
|
+
implementation("io.ktor:ktor-client-okhttp:3.1.3") // engine on device
|
|
85
|
+
implementation("io.ktor:ktor-client-content-negotiation:3.1.3")
|
|
86
|
+
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.3")
|
|
87
|
+
implementation("io.ktor:ktor-client-auth:3.1.3")
|
|
88
|
+
|
|
89
|
+
testImplementation("junit:junit:4.13.2")
|
|
90
|
+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
|
91
|
+
testImplementation("app.cash.turbine:turbine:1.2.0")
|
|
92
|
+
testImplementation("io.ktor:ktor-client-mock:3.1.3") // engine in tests
|
|
93
|
+
androidTestImplementation(composeBom)
|
|
94
|
+
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
|
95
|
+
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Pinned generator produces API/model/infrastructure sources from the committed server contract.
|
|
99
|
+
openApiGenerate {
|
|
100
|
+
generatorName.set("kotlin")
|
|
101
|
+
library.set("jvm-ktor")
|
|
102
|
+
inputSpec.set(file("../../api/openapi.json").absolutePath) // adapt to {api_spec}
|
|
103
|
+
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.absolutePath)
|
|
104
|
+
apiPackage.set("com.example.generated.api")
|
|
105
|
+
modelPackage.set("com.example.generated.model")
|
|
106
|
+
invokerPackage.set("com.example.generated.infrastructure")
|
|
107
|
+
packageName.set("com.example.generated")
|
|
108
|
+
configOptions.set(mapOf(
|
|
109
|
+
"serializationLibrary" to "kotlinx_serialization",
|
|
110
|
+
"dateLibrary" to "string",
|
|
111
|
+
"enumPropertyNaming" to "UPPERCASE",
|
|
112
|
+
"hideGenerationTimestamp" to "true",
|
|
113
|
+
"omitGradleWrapper" to "true",
|
|
114
|
+
"sourceFolder" to "src/main/kotlin",
|
|
115
|
+
))
|
|
116
|
+
// OpenAPI primitive unions are JSON scalars — never empty generated classes or Any.
|
|
117
|
+
schemaMappings.set(mapOf(
|
|
118
|
+
"Value" to "kotlinx.serialization.json.JsonPrimitive",
|
|
119
|
+
))
|
|
120
|
+
globalProperties.set(mapOf(
|
|
121
|
+
"apiDocs" to "false", "modelDocs" to "false",
|
|
122
|
+
"apiTests" to "false", "modelTests" to "false",
|
|
123
|
+
))
|
|
124
|
+
}
|
|
125
|
+
tasks.named("preBuild") { dependsOn(tasks.named("openApiGenerate")) }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```kotlin
|
|
129
|
+
// api-client/build.gradle.kts — JVM module: compiles the generated sources and hosts contract tests
|
|
130
|
+
plugins {
|
|
131
|
+
id("org.jetbrains.kotlin.jvm")
|
|
132
|
+
id("org.jetbrains.kotlin.plugin.serialization")
|
|
133
|
+
}
|
|
134
|
+
kotlin {
|
|
135
|
+
jvmToolchain(17)
|
|
136
|
+
sourceSets.getByName("main").kotlin.srcDir("../app/build/generated/openapi/src/main/kotlin")
|
|
137
|
+
}
|
|
138
|
+
dependencies {
|
|
139
|
+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
|
|
140
|
+
implementation("io.ktor:ktor-client-core:3.1.3") // no engine here on purpose
|
|
141
|
+
implementation("io.ktor:ktor-client-content-negotiation:3.1.3")
|
|
142
|
+
implementation("io.ktor:ktor-serialization-kotlinx-json:3.1.3")
|
|
143
|
+
testImplementation("io.ktor:ktor-client-mock:3.1.3")
|
|
144
|
+
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
|
145
|
+
testImplementation("junit:junit:4.13.2")
|
|
146
|
+
}
|
|
147
|
+
tasks.named("compileKotlin") { dependsOn(":app:openApiGenerate") }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Generate and run the contract tests without an emulator:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
./gradlew :app:openApiGenerate :api-client:test
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## §2. Shared `Json` codec (DI graph and tests use the same instance)
|
|
159
|
+
|
|
160
|
+
```kotlin
|
|
161
|
+
// api-client/src/main/kotlin/com/example/client/ApiJson.kt
|
|
162
|
+
package com.example.client
|
|
163
|
+
|
|
164
|
+
import java.math.BigDecimal
|
|
165
|
+
import kotlinx.serialization.ExperimentalSerializationApi
|
|
166
|
+
import kotlinx.serialization.KSerializer
|
|
167
|
+
import kotlinx.serialization.SerializationException
|
|
168
|
+
import kotlinx.serialization.descriptors.PrimitiveKind
|
|
169
|
+
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|
170
|
+
import kotlinx.serialization.encoding.Decoder
|
|
171
|
+
import kotlinx.serialization.encoding.Encoder
|
|
172
|
+
import kotlinx.serialization.json.*
|
|
173
|
+
import kotlinx.serialization.modules.SerializersModule
|
|
174
|
+
import kotlinx.serialization.modules.contextual
|
|
175
|
+
|
|
176
|
+
/** Single codec shared by the Hilt graph and the generated-client contract tests. */
|
|
177
|
+
object ApiJson {
|
|
178
|
+
fun create(): Json = Json {
|
|
179
|
+
ignoreUnknownKeys = true // additive server fields never break old clients
|
|
180
|
+
encodeDefaults = true
|
|
181
|
+
explicitNulls = true
|
|
182
|
+
// jvm-ktor marks OpenAPI `number` fields @Contextual BigDecimal; kotlinx has no built-in.
|
|
183
|
+
serializersModule = SerializersModule { contextual(BigDecimal::class, JsonDecimalSerializer) }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Exact JSON numbers — never quoted strings, never Double round-trips. */
|
|
188
|
+
@OptIn(ExperimentalSerializationApi::class)
|
|
189
|
+
private object JsonDecimalSerializer : KSerializer<BigDecimal> {
|
|
190
|
+
override val descriptor = PrimitiveSerialDescriptor("java.math.BigDecimal", PrimitiveKind.DOUBLE)
|
|
191
|
+
override fun deserialize(decoder: Decoder): BigDecimal {
|
|
192
|
+
val input = decoder as? JsonDecoder ?: throw SerializationException("Expected JSON decoder")
|
|
193
|
+
val number = input.decodeJsonElement() as? JsonPrimitive ?: throw SerializationException("Expected JSON number")
|
|
194
|
+
if (number.isString) throw SerializationException("Expected JSON number")
|
|
195
|
+
return number.content.toBigDecimalOrNull() ?: throw SerializationException("Expected JSON number")
|
|
196
|
+
}
|
|
197
|
+
override fun serialize(encoder: Encoder, value: BigDecimal) {
|
|
198
|
+
val output = encoder as? JsonEncoder ?: throw SerializationException("Expected JSON encoder")
|
|
199
|
+
output.encodeJsonElement(JsonUnquotedLiteral(value.toPlainString()))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## §3. Hilt network module: generated API over Ktor with the OkHttp engine
|
|
207
|
+
|
|
208
|
+
```kotlin
|
|
209
|
+
// app/src/main/java/com/example/app/di/NetworkModule.kt
|
|
210
|
+
package com.example.app.di
|
|
211
|
+
|
|
212
|
+
import com.example.app.BuildConfig
|
|
213
|
+
import com.example.app.data.auth.TokenStore
|
|
214
|
+
import com.example.client.ApiJson
|
|
215
|
+
import com.example.generated.api.TodosApi
|
|
216
|
+
import dagger.Module
|
|
217
|
+
import dagger.Provides
|
|
218
|
+
import dagger.hilt.InstallIn
|
|
219
|
+
import dagger.hilt.components.SingletonComponent
|
|
220
|
+
import io.ktor.client.HttpClientConfig
|
|
221
|
+
import io.ktor.client.engine.HttpClientEngine
|
|
222
|
+
import io.ktor.client.engine.okhttp.OkHttp
|
|
223
|
+
import io.ktor.client.plugins.HttpTimeout
|
|
224
|
+
import io.ktor.client.plugins.auth.Auth
|
|
225
|
+
import io.ktor.client.plugins.auth.providers.BearerTokens
|
|
226
|
+
import io.ktor.client.plugins.auth.providers.bearer
|
|
227
|
+
import io.ktor.client.plugins.defaultRequest
|
|
228
|
+
import io.ktor.client.plugins.logging.LogLevel
|
|
229
|
+
import io.ktor.client.plugins.logging.Logging
|
|
230
|
+
import io.ktor.client.request.header
|
|
231
|
+
import java.util.Locale
|
|
232
|
+
import java.util.concurrent.TimeUnit
|
|
233
|
+
import javax.inject.Singleton
|
|
234
|
+
import kotlinx.serialization.json.Json
|
|
235
|
+
|
|
236
|
+
@Module
|
|
237
|
+
@InstallIn(SingletonComponent::class)
|
|
238
|
+
object NetworkModule {
|
|
239
|
+
|
|
240
|
+
@Provides @Singleton
|
|
241
|
+
fun provideJson(): Json = ApiJson.create()
|
|
242
|
+
|
|
243
|
+
/** The engine is the ONLY Android-specific piece. Tests swap it for MockEngine. */
|
|
244
|
+
@Provides @Singleton
|
|
245
|
+
fun provideEngine(): HttpClientEngine = OkHttp.create {
|
|
246
|
+
config { // native OkHttpClient.Builder
|
|
247
|
+
connectTimeout(12, TimeUnit.SECONDS)
|
|
248
|
+
readTimeout(20, TimeUnit.SECONDS)
|
|
249
|
+
retryOnConnectionFailure(true)
|
|
250
|
+
}
|
|
251
|
+
// addInterceptor(...) for OkHttp-level concerns (certificate pinning, Sentry/OTel).
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Cross-cutting transport concerns only — auth, timeouts, headers, logging. Never caching. */
|
|
255
|
+
@Provides @Singleton
|
|
256
|
+
fun provideClientConfig(tokenStore: TokenStore): (HttpClientConfig<*>) -> Unit = { config ->
|
|
257
|
+
config.expectSuccess = false // generated HttpResponse<T>.success carries the status
|
|
258
|
+
config.install(HttpTimeout) { requestTimeoutMillis = 20_000 }
|
|
259
|
+
config.install(Auth) {
|
|
260
|
+
bearer {
|
|
261
|
+
loadTokens { tokenStore.current()?.let { BearerTokens(it.access, it.refresh) } }
|
|
262
|
+
refreshTokens { tokenStore.refresh()?.let { BearerTokens(it.access, it.refresh) } }
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
config.defaultRequest { header("Accept-Language", Locale.getDefault().toLanguageTag()) }
|
|
266
|
+
if (BuildConfig.DEBUG) config.install(Logging) { level = LogLevel.HEADERS }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* One generated *Api per OpenAPI tag. The pinned jvm-ktor template exposes
|
|
271
|
+
* `httpClientEngine` + `httpClientConfig` + `jsonSerializer` constructor hooks, so no
|
|
272
|
+
* template fork is needed. Parameter names follow the pinned generator version —
|
|
273
|
+
* confirm against build/generated/openapi/.../infrastructure/ApiClient.kt once.
|
|
274
|
+
*/
|
|
275
|
+
@Provides @Singleton
|
|
276
|
+
fun provideTodosApi(
|
|
277
|
+
engine: HttpClientEngine,
|
|
278
|
+
clientConfig: (HttpClientConfig<*>) -> Unit,
|
|
279
|
+
json: Json,
|
|
280
|
+
): TodosApi = TodosApi(
|
|
281
|
+
baseUrl = BuildConfig.API_BASE_URL.trimEnd('/'),
|
|
282
|
+
httpClientEngine = engine,
|
|
283
|
+
httpClientConfig = clientConfig,
|
|
284
|
+
jsonSerializer = json,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## §4. Repository wrapping the generated client (no hand-rolled HTTP)
|
|
292
|
+
|
|
293
|
+
```kotlin
|
|
294
|
+
// app/src/main/java/com/example/app/data/ApiFailure.kt
|
|
295
|
+
package com.example.app.data
|
|
296
|
+
|
|
297
|
+
sealed class ApiFailure(message: String) : Exception(message) {
|
|
298
|
+
class NotFound : ApiFailure("Resource not found")
|
|
299
|
+
class Conflict(val detail: String) : ApiFailure(detail)
|
|
300
|
+
class Unauthorized : ApiFailure("Session expired")
|
|
301
|
+
class Server(val status: Int) : ApiFailure("Server error $status")
|
|
302
|
+
class Network(cause: Throwable) : ApiFailure(cause.message ?: "Network error") { init { initCause(cause) } }
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
```kotlin
|
|
307
|
+
// app/src/main/java/com/example/app/data/TodoRepository.kt
|
|
308
|
+
package com.example.app.data
|
|
309
|
+
|
|
310
|
+
import com.example.generated.api.TodosApi
|
|
311
|
+
import com.example.generated.infrastructure.HttpResponse
|
|
312
|
+
import com.example.generated.model.CreateTodoRequest
|
|
313
|
+
import com.example.generated.model.Todo
|
|
314
|
+
import io.ktor.client.statement.bodyAsText
|
|
315
|
+
import java.io.IOException
|
|
316
|
+
import javax.inject.Inject
|
|
317
|
+
import javax.inject.Singleton
|
|
318
|
+
import kotlinx.coroutines.CancellationException
|
|
319
|
+
import kotlinx.serialization.json.Json
|
|
320
|
+
import kotlinx.serialization.json.jsonObject
|
|
321
|
+
import kotlinx.serialization.json.jsonPrimitive
|
|
322
|
+
|
|
323
|
+
/** Every HTTP operation is generated from the OpenAPI spec; this class owns mapping and errors only. */
|
|
324
|
+
@Singleton
|
|
325
|
+
class TodoRepository @Inject constructor(
|
|
326
|
+
private val api: TodosApi,
|
|
327
|
+
private val json: Json,
|
|
328
|
+
) {
|
|
329
|
+
suspend fun list(): List<Todo> = call { api.listTodos() }
|
|
330
|
+
suspend fun get(id: String): Todo = call { api.getTodo(id) }
|
|
331
|
+
suspend fun create(title: String): Todo = call { api.createTodo(CreateTodoRequest(title = title)) }
|
|
332
|
+
suspend fun toggle(id: String): Todo = call { api.toggleTodo(id) }
|
|
333
|
+
suspend fun delete(id: String) { call { api.deleteTodo(id) } }
|
|
334
|
+
|
|
335
|
+
/** Unwraps the generated HttpResponse<T>: status → typed failure, IOException → Network. */
|
|
336
|
+
private suspend fun <T> call(request: suspend () -> HttpResponse<T>): T {
|
|
337
|
+
val response = try {
|
|
338
|
+
request()
|
|
339
|
+
} catch (cancelled: CancellationException) {
|
|
340
|
+
throw cancelled // never swallow structured cancellation
|
|
341
|
+
} catch (io: IOException) {
|
|
342
|
+
throw ApiFailure.Network(io)
|
|
343
|
+
}
|
|
344
|
+
if (!response.success) {
|
|
345
|
+
val detail = runCatching {
|
|
346
|
+
json.parseToJsonElement(response.response.bodyAsText()).jsonObject["detail"]?.jsonPrimitive?.content
|
|
347
|
+
}.getOrNull().orEmpty()
|
|
348
|
+
throw when (response.status) {
|
|
349
|
+
401 -> ApiFailure.Unauthorized()
|
|
350
|
+
404 -> ApiFailure.NotFound()
|
|
351
|
+
409 -> ApiFailure.Conflict(detail)
|
|
352
|
+
else -> ApiFailure.Server(response.status)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return response.body()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## §5. `@HiltViewModel` with a single `StateFlow<UiState>`
|
|
363
|
+
|
|
364
|
+
```kotlin
|
|
365
|
+
// app/src/main/java/com/example/app/ui/todos/TodoListViewModel.kt
|
|
366
|
+
package com.example.app.ui.todos
|
|
367
|
+
|
|
368
|
+
import androidx.lifecycle.ViewModel
|
|
369
|
+
import androidx.lifecycle.viewModelScope
|
|
370
|
+
import com.example.app.data.ApiFailure
|
|
371
|
+
import com.example.app.data.TodoRepository
|
|
372
|
+
import com.example.generated.model.Todo
|
|
373
|
+
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
374
|
+
import javax.inject.Inject
|
|
375
|
+
import kotlinx.coroutines.Job
|
|
376
|
+
import kotlinx.coroutines.flow.MutableStateFlow
|
|
377
|
+
import kotlinx.coroutines.flow.StateFlow
|
|
378
|
+
import kotlinx.coroutines.flow.asStateFlow
|
|
379
|
+
import kotlinx.coroutines.flow.update
|
|
380
|
+
import kotlinx.coroutines.launch
|
|
381
|
+
|
|
382
|
+
sealed interface TodoListUiState {
|
|
383
|
+
data object Loading : TodoListUiState
|
|
384
|
+
data class Error(val message: String, val retryable: Boolean = true) : TodoListUiState
|
|
385
|
+
data class Content(val todos: List<Todo>, val busy: Boolean = false) : TodoListUiState
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
@HiltViewModel
|
|
389
|
+
class TodoListViewModel @Inject constructor(
|
|
390
|
+
private val repository: TodoRepository,
|
|
391
|
+
) : ViewModel() {
|
|
392
|
+
|
|
393
|
+
private val _state = MutableStateFlow<TodoListUiState>(TodoListUiState.Loading)
|
|
394
|
+
val state: StateFlow<TodoListUiState> = _state.asStateFlow()
|
|
395
|
+
|
|
396
|
+
private var loadJob: Job? = null
|
|
397
|
+
|
|
398
|
+
init { load() }
|
|
399
|
+
|
|
400
|
+
fun load() {
|
|
401
|
+
loadJob?.cancel() // one in-flight load at a time
|
|
402
|
+
loadJob = viewModelScope.launch {
|
|
403
|
+
_state.value = TodoListUiState.Loading
|
|
404
|
+
_state.value = try {
|
|
405
|
+
TodoListUiState.Content(repository.list())
|
|
406
|
+
} catch (failure: ApiFailure) {
|
|
407
|
+
TodoListUiState.Error(failure.toUserMessage(), retryable = failure !is ApiFailure.Unauthorized)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
fun toggle(id: String) = mutate { repository.toggle(id) }
|
|
413
|
+
fun delete(id: String) = mutate { repository.delete(id) }
|
|
414
|
+
fun create(title: String) = mutate { repository.create(title.trim()) }
|
|
415
|
+
|
|
416
|
+
private fun mutate(block: suspend () -> Unit) {
|
|
417
|
+
val current = _state.value as? TodoListUiState.Content ?: return
|
|
418
|
+
viewModelScope.launch {
|
|
419
|
+
_state.value = current.copy(busy = true)
|
|
420
|
+
try {
|
|
421
|
+
block()
|
|
422
|
+
_state.value = TodoListUiState.Content(repository.list())
|
|
423
|
+
} catch (failure: ApiFailure) {
|
|
424
|
+
_state.update { TodoListUiState.Error(failure.toUserMessage()) }
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private fun ApiFailure.toUserMessage(): String = when (this) {
|
|
431
|
+
is ApiFailure.Network -> "You appear to be offline."
|
|
432
|
+
is ApiFailure.Unauthorized -> "Please sign in again."
|
|
433
|
+
is ApiFailure.NotFound -> "That item no longer exists."
|
|
434
|
+
is ApiFailure.Conflict -> detail.ifBlank { "That change conflicts with the server." }
|
|
435
|
+
is ApiFailure.Server -> "Something went wrong on our side. Try again."
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
`viewModelScope` is cancelled in `onCleared()`, so every network call is structurally bound to the screen. Never launch into `GlobalScope`.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## §6. Compose screen: loading / error-with-retry / empty / content
|
|
444
|
+
|
|
445
|
+
```kotlin
|
|
446
|
+
// app/src/main/java/com/example/app/ui/todos/TodoListScreen.kt
|
|
447
|
+
package com.example.app.ui.todos
|
|
448
|
+
|
|
449
|
+
import androidx.compose.foundation.layout.*
|
|
450
|
+
import androidx.compose.foundation.lazy.LazyColumn
|
|
451
|
+
import androidx.compose.foundation.lazy.items
|
|
452
|
+
import androidx.compose.material3.*
|
|
453
|
+
import androidx.compose.runtime.Composable
|
|
454
|
+
import androidx.compose.runtime.getValue
|
|
455
|
+
import androidx.compose.ui.Alignment
|
|
456
|
+
import androidx.compose.ui.Modifier
|
|
457
|
+
import androidx.compose.ui.unit.dp
|
|
458
|
+
import androidx.hilt.navigation.compose.hiltViewModel
|
|
459
|
+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
460
|
+
import com.example.generated.model.Todo
|
|
461
|
+
|
|
462
|
+
@Composable
|
|
463
|
+
fun TodoListScreen(
|
|
464
|
+
onOpenDetail: (String) -> Unit,
|
|
465
|
+
viewModel: TodoListViewModel = hiltViewModel(),
|
|
466
|
+
) {
|
|
467
|
+
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
468
|
+
TodoListContent(
|
|
469
|
+
state = state,
|
|
470
|
+
onRetry = viewModel::load,
|
|
471
|
+
onToggle = viewModel::toggle,
|
|
472
|
+
onOpenDetail = onOpenDetail,
|
|
473
|
+
)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Stateless: previewable and testable without Hilt. */
|
|
477
|
+
@Composable
|
|
478
|
+
fun TodoListContent(
|
|
479
|
+
state: TodoListUiState,
|
|
480
|
+
onRetry: () -> Unit,
|
|
481
|
+
onToggle: (String) -> Unit,
|
|
482
|
+
onOpenDetail: (String) -> Unit,
|
|
483
|
+
modifier: Modifier = Modifier,
|
|
484
|
+
) {
|
|
485
|
+
Scaffold(topBar = { TopAppBar(title = { Text("Todos") }) }, modifier = modifier) { padding ->
|
|
486
|
+
Box(Modifier.padding(padding).fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
487
|
+
when (state) {
|
|
488
|
+
TodoListUiState.Loading -> CircularProgressIndicator()
|
|
489
|
+
is TodoListUiState.Error -> ErrorState(state.message, state.retryable, onRetry)
|
|
490
|
+
is TodoListUiState.Content ->
|
|
491
|
+
if (state.todos.isEmpty()) EmptyState()
|
|
492
|
+
else TodoList(state.todos, state.busy, onToggle, onOpenDetail)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
@Composable
|
|
499
|
+
private fun TodoList(
|
|
500
|
+
todos: List<Todo>,
|
|
501
|
+
busy: Boolean,
|
|
502
|
+
onToggle: (String) -> Unit,
|
|
503
|
+
onOpenDetail: (String) -> Unit,
|
|
504
|
+
) {
|
|
505
|
+
LazyColumn(Modifier.fillMaxSize()) {
|
|
506
|
+
items(todos, key = { it.id }) { todo ->
|
|
507
|
+
ListItem(
|
|
508
|
+
headlineContent = { Text(todo.title) },
|
|
509
|
+
leadingContent = {
|
|
510
|
+
Checkbox(checked = todo.completed, enabled = !busy, onCheckedChange = { onToggle(todo.id) })
|
|
511
|
+
},
|
|
512
|
+
modifier = Modifier.clickable { onOpenDetail(todo.id) },
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
@Composable
|
|
519
|
+
private fun ErrorState(message: String, retryable: Boolean, onRetry: () -> Unit) {
|
|
520
|
+
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
521
|
+
Text(message, style = MaterialTheme.typography.bodyLarge)
|
|
522
|
+
if (retryable) Button(onClick = onRetry) { Text("Retry") }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
@Composable
|
|
527
|
+
private fun EmptyState() {
|
|
528
|
+
Text("Nothing to do yet.", style = MaterialTheme.typography.bodyLarge)
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
534
|
+
## §7. Navigation Compose with type-safe `@Serializable` routes
|
|
535
|
+
|
|
536
|
+
```kotlin
|
|
537
|
+
// app/src/main/java/com/example/app/ui/navigation/AppNavHost.kt
|
|
538
|
+
package com.example.app.ui.navigation
|
|
539
|
+
|
|
540
|
+
import androidx.compose.runtime.Composable
|
|
541
|
+
import androidx.navigation.compose.NavHost
|
|
542
|
+
import androidx.navigation.compose.composable
|
|
543
|
+
import androidx.navigation.compose.rememberNavController
|
|
544
|
+
import androidx.navigation.toRoute
|
|
545
|
+
import com.example.app.ui.todos.TodoDetailScreen
|
|
546
|
+
import com.example.app.ui.todos.TodoListScreen
|
|
547
|
+
import kotlinx.serialization.Serializable
|
|
548
|
+
|
|
549
|
+
@Serializable data object TodoListRoute
|
|
550
|
+
@Serializable data class TodoDetailRoute(val id: String)
|
|
551
|
+
|
|
552
|
+
@Composable
|
|
553
|
+
fun AppNavHost() {
|
|
554
|
+
val navController = rememberNavController()
|
|
555
|
+
NavHost(navController, startDestination = TodoListRoute) {
|
|
556
|
+
composable<TodoListRoute> {
|
|
557
|
+
TodoListScreen(onOpenDetail = { id -> navController.navigate(TodoDetailRoute(id)) })
|
|
558
|
+
}
|
|
559
|
+
composable<TodoDetailRoute> { entry ->
|
|
560
|
+
val route = entry.toRoute<TodoDetailRoute>()
|
|
561
|
+
TodoDetailScreen(id = route.id, onBack = { navController.popBackStack() })
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
Screens receive callbacks, never the `NavController`. Enable predictive back once in `AndroidManifest.xml` (`android:enableOnBackInvokedCallback="true"`); the `NavHost` handles it.
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
## §8. Repository-layer response cache (Room, stale-while-revalidate)
|
|
572
|
+
|
|
573
|
+
```kotlin
|
|
574
|
+
// app/src/main/java/com/example/app/data/cache/TodoCache.kt
|
|
575
|
+
package com.example.app.data.cache
|
|
576
|
+
|
|
577
|
+
import androidx.room.*
|
|
578
|
+
import kotlinx.coroutines.flow.Flow
|
|
579
|
+
|
|
580
|
+
@Entity(tableName = "todo_cache")
|
|
581
|
+
data class TodoCacheRow(
|
|
582
|
+
@PrimaryKey val id: String,
|
|
583
|
+
val title: String,
|
|
584
|
+
val completed: Boolean,
|
|
585
|
+
val fetchedAt: Long, // epoch millis — explicit TTL lives in the repository
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
@Dao
|
|
589
|
+
interface TodoCacheDao {
|
|
590
|
+
@Query("SELECT * FROM todo_cache ORDER BY title")
|
|
591
|
+
fun observeAll(): Flow<List<TodoCacheRow>>
|
|
592
|
+
|
|
593
|
+
@Query("SELECT MIN(fetchedAt) FROM todo_cache")
|
|
594
|
+
suspend fun oldestFetchedAt(): Long?
|
|
595
|
+
|
|
596
|
+
@Transaction
|
|
597
|
+
suspend fun replaceAll(rows: List<TodoCacheRow>) { clear(); upsertAll(rows) }
|
|
598
|
+
|
|
599
|
+
@Upsert suspend fun upsertAll(rows: List<TodoCacheRow>)
|
|
600
|
+
@Upsert suspend fun upsert(row: TodoCacheRow)
|
|
601
|
+
@Query("DELETE FROM todo_cache WHERE id = :id") suspend fun delete(id: String)
|
|
602
|
+
@Query("DELETE FROM todo_cache") suspend fun clear()
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
@Database(entities = [TodoCacheRow::class], version = 1, exportSchema = true)
|
|
606
|
+
abstract class AppDatabase : RoomDatabase() {
|
|
607
|
+
abstract fun todoCache(): TodoCacheDao
|
|
608
|
+
}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
```kotlin
|
|
612
|
+
// app/src/main/java/com/example/app/data/CachedTodoRepository.kt
|
|
613
|
+
package com.example.app.data
|
|
614
|
+
|
|
615
|
+
import com.example.app.data.cache.TodoCacheDao
|
|
616
|
+
import com.example.app.data.cache.TodoCacheRow
|
|
617
|
+
import com.example.generated.api.TodosApi
|
|
618
|
+
import com.example.generated.model.Todo
|
|
619
|
+
import javax.inject.Inject
|
|
620
|
+
import javax.inject.Singleton
|
|
621
|
+
import kotlin.time.Duration.Companion.minutes
|
|
622
|
+
import kotlinx.coroutines.flow.Flow
|
|
623
|
+
import kotlinx.coroutines.flow.map
|
|
624
|
+
import kotlinx.coroutines.flow.onStart
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Reads: emit cached rows immediately, revalidate when stale, let Room re-emit.
|
|
628
|
+
* Writes: hit the server, then upsert/delete the affected rows — never a blind refetch.
|
|
629
|
+
* Cache key = operation ("listTodos") + params; TTL is explicit.
|
|
630
|
+
*/
|
|
631
|
+
@Singleton
|
|
632
|
+
class CachedTodoRepository @Inject constructor(
|
|
633
|
+
private val api: TodosApi,
|
|
634
|
+
private val dao: TodoCacheDao,
|
|
635
|
+
private val remote: TodoRepository, // §4 — owns HttpResponse unwrapping
|
|
636
|
+
private val clock: () -> Long = System::currentTimeMillis,
|
|
637
|
+
) {
|
|
638
|
+
private val ttl = 5.minutes.inWholeMilliseconds
|
|
639
|
+
|
|
640
|
+
fun observeTodos(): Flow<List<Todo>> = dao.observeAll()
|
|
641
|
+
.map { rows -> rows.map { it.toModel() } }
|
|
642
|
+
.onStart { revalidateIfStale() } // fire-and-forget inside the collector's scope
|
|
643
|
+
|
|
644
|
+
private suspend fun revalidateIfStale() {
|
|
645
|
+
val oldest = dao.oldestFetchedAt()
|
|
646
|
+
if (oldest != null && clock() - oldest < ttl) return
|
|
647
|
+
try {
|
|
648
|
+
val fresh = remote.list()
|
|
649
|
+
dao.replaceAll(fresh.map { it.toRow(clock()) })
|
|
650
|
+
} catch (failure: ApiFailure) {
|
|
651
|
+
if (oldest == null) throw failure // nothing cached → surface the error
|
|
652
|
+
// warm cache → keep serving stale rows, swallow
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
suspend fun toggle(id: String) { dao.upsert(remote.toggle(id).toRow(clock())) }
|
|
657
|
+
suspend fun create(title: String) { dao.upsert(remote.create(title).toRow(clock())) }
|
|
658
|
+
suspend fun delete(id: String) { remote.delete(id); dao.delete(id) }
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private fun TodoCacheRow.toModel() = Todo(id = id, title = title, completed = completed)
|
|
662
|
+
private fun Todo.toRow(now: Long) = TodoCacheRow(id = id, title = title, completed = completed, fetchedAt = now)
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
```kotlin
|
|
666
|
+
// app/src/main/java/com/example/app/di/DatabaseModule.kt
|
|
667
|
+
@Module @InstallIn(SingletonComponent::class)
|
|
668
|
+
object DatabaseModule {
|
|
669
|
+
@Provides @Singleton
|
|
670
|
+
fun provideDb(@ApplicationContext ctx: Context): AppDatabase =
|
|
671
|
+
Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()
|
|
672
|
+
@Provides fun provideTodoCache(db: AppDatabase): TodoCacheDao = db.todoCache()
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
## §9. Generated-client contract test with `MockEngine` (`:api-client`, no Android)
|
|
679
|
+
|
|
680
|
+
```kotlin
|
|
681
|
+
// api-client/src/test/kotlin/com/example/client/GeneratedClientContractTest.kt
|
|
682
|
+
package com.example.client
|
|
683
|
+
|
|
684
|
+
import com.example.generated.api.TodosApi
|
|
685
|
+
import com.example.generated.model.CreateTodoRequest
|
|
686
|
+
import io.ktor.client.engine.mock.MockEngine
|
|
687
|
+
import io.ktor.client.engine.mock.respond
|
|
688
|
+
import io.ktor.http.*
|
|
689
|
+
import io.ktor.http.content.TextContent
|
|
690
|
+
import kotlinx.coroutines.test.runTest
|
|
691
|
+
import org.junit.Assert.*
|
|
692
|
+
import org.junit.Test
|
|
693
|
+
|
|
694
|
+
class GeneratedClientContractTest {
|
|
695
|
+
private val json = ApiJson.create()
|
|
696
|
+
|
|
697
|
+
@Test fun `create sends kotlinx-encoded body and decodes typed response`() = runTest {
|
|
698
|
+
var sentBody = ""
|
|
699
|
+
val engine = MockEngine { request ->
|
|
700
|
+
assertEquals("/todos", request.url.encodedPath)
|
|
701
|
+
assertEquals(HttpMethod.Post, request.method)
|
|
702
|
+
sentBody = (request.body as TextContent).text
|
|
703
|
+
respond(
|
|
704
|
+
content = """{"id":"t1","title":"Buy milk","completed":false}""",
|
|
705
|
+
status = HttpStatusCode.Created,
|
|
706
|
+
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()),
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
val api = TodosApi(baseUrl = "http://localhost", httpClientEngine = engine, jsonSerializer = json)
|
|
710
|
+
|
|
711
|
+
val response = api.createTodo(CreateTodoRequest(title = "Buy milk"))
|
|
712
|
+
|
|
713
|
+
assertTrue(response.success)
|
|
714
|
+
assertEquals(201, response.status)
|
|
715
|
+
assertEquals("Buy milk", response.body().title)
|
|
716
|
+
assertTrue(sentBody.contains("\"title\":\"Buy milk\""))
|
|
717
|
+
engine.close()
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
@Test fun `non-2xx is surfaced through success=false, not an exception`() = runTest {
|
|
721
|
+
val engine = MockEngine { respond("""{"detail":"not found"}""", HttpStatusCode.NotFound) }
|
|
722
|
+
val api = TodosApi(baseUrl = "http://localhost", httpClientEngine = engine, jsonSerializer = json)
|
|
723
|
+
|
|
724
|
+
val response = api.getTodo("missing")
|
|
725
|
+
|
|
726
|
+
assertFalse(response.success)
|
|
727
|
+
assertEquals(404, response.status)
|
|
728
|
+
engine.close()
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
Because the engine is injected, this test exercises the *real* generated code path (URL building, serialization, response unwrapping) with zero network.
|
|
734
|
+
|
|
735
|
+
---
|
|
736
|
+
|
|
737
|
+
## §10. ViewModel unit test (`runTest` + fake repository + Turbine)
|
|
738
|
+
|
|
739
|
+
```kotlin
|
|
740
|
+
// app/src/test/java/com/example/app/ui/todos/TodoListViewModelTest.kt
|
|
741
|
+
package com.example.app.ui.todos
|
|
742
|
+
|
|
743
|
+
import app.cash.turbine.test
|
|
744
|
+
import com.example.app.data.ApiFailure
|
|
745
|
+
import com.example.app.data.TodoRepository
|
|
746
|
+
import com.example.generated.model.Todo
|
|
747
|
+
import kotlinx.coroutines.Dispatchers
|
|
748
|
+
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
749
|
+
import kotlinx.coroutines.test.*
|
|
750
|
+
import org.junit.After
|
|
751
|
+
import org.junit.Assert.*
|
|
752
|
+
import org.junit.Before
|
|
753
|
+
import org.junit.Test
|
|
754
|
+
|
|
755
|
+
@OptIn(ExperimentalCoroutinesApi::class)
|
|
756
|
+
class TodoListViewModelTest {
|
|
757
|
+
private val dispatcher = StandardTestDispatcher()
|
|
758
|
+
|
|
759
|
+
@Before fun setUp() = Dispatchers.setMain(dispatcher)
|
|
760
|
+
@After fun tearDown() = Dispatchers.resetMain()
|
|
761
|
+
|
|
762
|
+
@Test fun `loads todos into Content`() = runTest(dispatcher) {
|
|
763
|
+
val repo = FakeTodoRepository(listOf(Todo(id = "1", title = "A", completed = false)))
|
|
764
|
+
val vm = TodoListViewModel(repo)
|
|
765
|
+
|
|
766
|
+
vm.state.test {
|
|
767
|
+
assertEquals(TodoListUiState.Loading, awaitItem())
|
|
768
|
+
val content = awaitItem() as TodoListUiState.Content
|
|
769
|
+
assertEquals(listOf("A"), content.todos.map { it.title })
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
@Test fun `network failure becomes retryable Error`() = runTest(dispatcher) {
|
|
774
|
+
val vm = TodoListViewModel(FakeTodoRepository(failure = ApiFailure.Network(java.io.IOException("offline"))))
|
|
775
|
+
|
|
776
|
+
vm.state.test {
|
|
777
|
+
awaitItem() // Loading
|
|
778
|
+
val error = awaitItem() as TodoListUiState.Error
|
|
779
|
+
assertTrue(error.retryable)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** Repository is a concrete class; make it `open` or extract an interface seam for fakes. */
|
|
785
|
+
private class FakeTodoRepository(
|
|
786
|
+
private val todos: List<Todo> = emptyList(),
|
|
787
|
+
private val failure: ApiFailure? = null,
|
|
788
|
+
) : TodoRepository(api = error("unused"), json = error("unused")) {
|
|
789
|
+
override suspend fun list(): List<Todo> = failure?.let { throw it } ?: todos
|
|
790
|
+
}
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
Prefer an interface (`TodoDataSource`) that both the real repository and the fake implement; the subclass trick above is the minimal seam when the project has not introduced one.
|
|
794
|
+
|
|
795
|
+
---
|
|
796
|
+
|
|
797
|
+
## §11. Compose UI test + Maestro flow
|
|
798
|
+
|
|
799
|
+
```kotlin
|
|
800
|
+
// app/src/androidTest/java/com/example/app/ui/todos/TodoListContentTest.kt
|
|
801
|
+
@RunWith(AndroidJUnit4::class)
|
|
802
|
+
class TodoListContentTest {
|
|
803
|
+
@get:Rule val compose = createComposeRule()
|
|
804
|
+
|
|
805
|
+
@Test fun errorStateShowsRetry() {
|
|
806
|
+
var retried = false
|
|
807
|
+
compose.setContent {
|
|
808
|
+
TodoListContent(
|
|
809
|
+
state = TodoListUiState.Error("Offline"),
|
|
810
|
+
onRetry = { retried = true }, onToggle = {}, onOpenDetail = {},
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
compose.onNodeWithText("Retry").performClick()
|
|
814
|
+
assertTrue(retried)
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
```yaml
|
|
820
|
+
# e2e/todo_create.yaml (Maestro)
|
|
821
|
+
appId: com.example.app
|
|
822
|
+
---
|
|
823
|
+
- launchApp
|
|
824
|
+
- assertVisible: "Todos"
|
|
825
|
+
- tapOn: "Add"
|
|
826
|
+
- inputText: "Buy milk"
|
|
827
|
+
- tapOn: "Save"
|
|
828
|
+
- assertVisible: "Buy milk"
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
## §12. Secrets: Keystore-backed token storage (never plain `SharedPreferences`)
|
|
834
|
+
|
|
835
|
+
```kotlin
|
|
836
|
+
// app/src/main/java/com/example/app/data/auth/TokenStore.kt
|
|
837
|
+
package com.example.app.data.auth
|
|
838
|
+
|
|
839
|
+
import android.content.Context
|
|
840
|
+
import androidx.security.crypto.EncryptedSharedPreferences
|
|
841
|
+
import androidx.security.crypto.MasterKey
|
|
842
|
+
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
843
|
+
import javax.inject.Inject
|
|
844
|
+
import javax.inject.Singleton
|
|
845
|
+
|
|
846
|
+
data class Tokens(val access: String, val refresh: String)
|
|
847
|
+
|
|
848
|
+
@Singleton
|
|
849
|
+
class TokenStore @Inject constructor(@ApplicationContext ctx: Context) {
|
|
850
|
+
private val prefs = EncryptedSharedPreferences.create(
|
|
851
|
+
ctx, "auth",
|
|
852
|
+
MasterKey.Builder(ctx).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build(),
|
|
853
|
+
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
854
|
+
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
fun current(): Tokens? {
|
|
858
|
+
val access = prefs.getString("access", null) ?: return null
|
|
859
|
+
val refresh = prefs.getString("refresh", null) ?: return null
|
|
860
|
+
return Tokens(access, refresh)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
fun save(tokens: Tokens) = prefs.edit().putString("access", tokens.access).putString("refresh", tokens.refresh).apply()
|
|
864
|
+
fun clear() = prefs.edit().clear().apply()
|
|
865
|
+
|
|
866
|
+
/** Called by Ktor's Auth plugin (§3) on 401; perform the refresh through the generated AuthApi. */
|
|
867
|
+
suspend fun refresh(): Tokens? = null // TODO: wire to AuthApi.refreshToken(); return null to force re-login
|
|
868
|
+
}
|
|
869
|
+
```
|
|
870
|
+
|
|
871
|
+
Tokens never enter Room, DataStore, or logs. The Ktor `Auth` plugin in §3 reads from this store so no call site attaches headers manually.
|