Skip to content

Commit 2fd1db2

Browse files
committed
feat: KMP scaffold, CI, encrypted DB, secure storage
Stand up the mobile source tree under mobile/ with a Kotlin Multiplatform shared module, Jetpack Compose Android host, SwiftUI iOS host (via XcodeGen), and GitHub Actions CI. Shared module: - MyFaqApi wrapper + /meta DTO with MockEngine for Phase 0 - SQLDelight schema v1 (instances table), DatabaseFactory with passphrase management via SecureStore - SecureStore: EncryptedSharedPreferences (Android), Keychain (iOS) - DatabaseDriverFactory: SQLCipher (Android), Data Protection (iOS) - Entitlements facade stub (always false) - Koin DI wiring Host apps: - Android: Compose Material 3 PhaseZeroScreen rendering /meta - iOS: SwiftUI ContentView rendering /meta, XcodeGen project.yml
1 parent b7de85f commit 2fd1db2

59 files changed

Lines changed: 4606 additions & 37 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/mobile-ci.yml

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
name: mobile-ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'mobile/**'
8+
- '.github/workflows/mobile-*.yml'
9+
pull_request:
10+
paths:
11+
- 'mobile/**'
12+
- '.github/workflows/mobile-*.yml'
13+
14+
concurrency:
15+
group: mobile-ci-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
defaults:
19+
run:
20+
working-directory: mobile
21+
22+
jobs:
23+
lint:
24+
name: Lint (ktlint + detekt)
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v4
28+
- uses: actions/setup-java@v4
29+
with:
30+
distribution: temurin
31+
java-version: '17'
32+
- uses: gradle/actions/setup-gradle@v4
33+
- run: ./gradlew ktlintCheck detekt --stacktrace
34+
35+
shared-unit:
36+
name: Shared unit tests
37+
runs-on: ubuntu-latest
38+
needs: lint
39+
steps:
40+
- uses: actions/checkout@v4
41+
- uses: actions/setup-java@v4
42+
with:
43+
distribution: temurin
44+
java-version: '17'
45+
- uses: gradle/actions/setup-gradle@v4
46+
- run: ./gradlew :shared:testDebugUnitTest :shared:jvmTest --stacktrace
47+
- uses: actions/upload-artifact@v4
48+
if: always()
49+
with:
50+
name: shared-unit-reports
51+
path: mobile/shared/build/reports/tests/
52+
53+
android-build:
54+
name: Android debug build
55+
runs-on: ubuntu-latest
56+
needs: shared-unit
57+
steps:
58+
- uses: actions/checkout@v4
59+
- uses: actions/setup-java@v4
60+
with:
61+
distribution: temurin
62+
java-version: '17'
63+
- uses: gradle/actions/setup-gradle@v4
64+
- run: ./gradlew :androidApp:assembleDebug --stacktrace
65+
- uses: actions/upload-artifact@v4
66+
with:
67+
name: androidApp-debug-apk
68+
path: mobile/androidApp/build/outputs/apk/debug/*.apk
69+
70+
ios-build:
71+
name: iOS simulator build
72+
runs-on: macos-latest
73+
needs: lint
74+
steps:
75+
- uses: actions/checkout@v4
76+
- uses: actions/setup-java@v4
77+
with:
78+
distribution: temurin
79+
java-version: '17'
80+
- uses: gradle/actions/setup-gradle@v4
81+
- name: Install XcodeGen
82+
run: brew install xcodegen
83+
- name: Generate Xcode project
84+
working-directory: mobile/iosApp
85+
run: xcodegen generate
86+
- name: Assemble shared XCFramework
87+
run: ./gradlew :shared:assembleSharedDebugXCFramework --stacktrace
88+
- name: Build iOS app
89+
working-directory: mobile/iosApp
90+
run: |
91+
xcodebuild \
92+
-project iosApp.xcodeproj \
93+
-scheme iosApp \
94+
-configuration Debug \
95+
-destination 'generic/platform=iOS Simulator' \
96+
-skipPackagePluginValidation \
97+
CODE_SIGNING_ALLOWED=NO \
98+
build
99+
100+
shared-ios-test:
101+
name: Shared iOS simulator tests
102+
runs-on: macos-latest
103+
needs: shared-unit
104+
steps:
105+
- uses: actions/checkout@v4
106+
- uses: actions/setup-java@v4
107+
with:
108+
distribution: temurin
109+
java-version: '17'
110+
- uses: gradle/actions/setup-gradle@v4
111+
- run: ./gradlew :shared:iosSimulatorArm64Test --stacktrace
112+
- uses: actions/upload-artifact@v4
113+
if: always()
114+
with:
115+
name: shared-ios-reports
116+
path: mobile/shared/build/reports/tests/
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: mobile-openapi-sync
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
phpmyfaq_ref:
7+
description: 'phpMyFAQ git ref (tag or branch) to pull the spec from'
8+
required: true
9+
default: '4.2.0'
10+
repository_dispatch:
11+
types: [phpmyfaq-release]
12+
13+
jobs:
14+
sync:
15+
name: Regenerate API client
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
with:
20+
fetch-depth: 0
21+
22+
- name: Resolve phpMyFAQ ref
23+
id: resolve
24+
run: |
25+
if [[ "${{ github.event_name }}" == "repository_dispatch" ]]; then
26+
echo "ref=${{ github.event.client_payload.ref }}" >> "$GITHUB_OUTPUT"
27+
else
28+
echo "ref=${{ inputs.phpmyfaq_ref }}" >> "$GITHUB_OUTPUT"
29+
fi
30+
31+
- name: Download OpenAPI spec
32+
run: |
33+
curl -fsSL \
34+
"https://raw.githubusercontent.com/thorsten/phpMyFAQ/${{ steps.resolve.outputs.ref }}/docs/openapi.yaml" \
35+
-o mobile/spec/openapi/v3.2.yaml
36+
echo "${{ steps.resolve.outputs.ref }}" > mobile/spec/openapi/VERSION
37+
38+
- uses: actions/setup-java@v4
39+
with:
40+
distribution: temurin
41+
java-version: '17'
42+
43+
- name: Regenerate client
44+
run: mobile/scripts/generate-api-client.sh
45+
46+
- name: Open pull request
47+
uses: peter-evans/create-pull-request@v6
48+
with:
49+
branch: mobile/openapi-sync-${{ steps.resolve.outputs.ref }}
50+
title: "mobile: regenerate API client for phpMyFAQ ${{ steps.resolve.outputs.ref }}"
51+
commit-message: "mobile: regenerate API client for phpMyFAQ ${{ steps.resolve.outputs.ref }}"
52+
body: |
53+
Automated sync of the pinned OpenAPI spec and generated Kotlin client.
54+
55+
Source ref: `${{ steps.resolve.outputs.ref }}`
56+
57+
Review the diff carefully before merging — generator changes can
58+
subtly shift serialization behaviour.
59+
labels: mobile, openapi-sync
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: mobile-release
2+
3+
# Phase 0: committed but inactive. The first real use lands in Phase 1
4+
# once the `mobile-v0.1.0` tag is cut and signing secrets are added
5+
# to the repository.
6+
7+
on:
8+
push:
9+
tags:
10+
- 'mobile-v*'
11+
workflow_dispatch:
12+
13+
defaults:
14+
run:
15+
working-directory: mobile
16+
17+
jobs:
18+
placeholder:
19+
name: Release pipeline placeholder
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v4
23+
- name: Phase 0 notice
24+
run: |
25+
echo "Phase 0 release workflow is a placeholder."
26+
echo "Signed artifact production lands in Phase 1."
27+
exit 0

.gitignore

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# macOS
2+
.DS_Store
3+
4+
# IDE
5+
.idea/
6+
*.iml
7+
.vscode/
8+
*.swp
9+
10+
# Gradle / Kotlin
11+
mobile/.gradle/
12+
mobile/build/
13+
mobile/**/build/
14+
mobile/buildSrc/build/
15+
mobile/local.properties
16+
mobile/.kotlin/
17+
18+
# Android
19+
mobile/**/captures/
20+
mobile/**/release/
21+
mobile/**/debug/
22+
*.apk
23+
*.aab
24+
25+
# Xcode / iOS
26+
mobile/iosApp/iosApp.xcodeproj/
27+
mobile/iosApp/iosApp.xcworkspace/
28+
mobile/iosApp/Pods/
29+
mobile/iosApp/*.xcuserstate
30+
mobile/iosApp/DerivedData/
31+
xcuserdata/
32+
*.xcworkspace/xcuserdata/
33+
34+
# Generated API client — kept under version control, but build outputs are not
35+
mobile/shared/build/generated/
36+
37+
# Secrets
38+
*.keystore
39+
*.jks
40+
!debug.keystore
41+
google-services.json
42+
GoogleService-Info.plist

CLAUDE.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# MyFAQ.app
2+
3+
Native iOS + Android client for phpMyFAQ. Planning stage — no code yet, only `plans/mobile-app-plan.md`.
4+
5+
## Status
6+
7+
- Repo: `phpMyFAQ/MyFAQ` (this checkout). Current contents: `plans/`, `LICENSE`. Source tree lands in Phase 0.
8+
- App versions track phpMyFAQ minor versions (app `1.0.0` → phpMyFAQ `4.2.x`).
9+
10+
## Locked decisions (do not re-open without deliberate revisit)
11+
12+
- **Stack**: Kotlin Multiplatform shared module; SwiftUI (iOS); Jetpack Compose (Android).
13+
- **Brand**: MyFAQ.app, domain `https://myfaq.app`.
14+
- **Model**: freemium. Read + offline free forever. Writes (login, ask, comment, rate, register) behind Pro unlock in Phase 3.
15+
- **Minimum phpMyFAQ version**: `4.2.0`. No back-compat to `v3.1`. OpenAPI client generated from 4.2.x spec only.
16+
- **iOS bundle ID**: `app.myfaq.ios`. Irreversible in store.
17+
- **Marketing site**: `https://myfaq.app` is a single landing page with download badges at launch. No mirrored docs.
18+
19+
## Scope guardrails
20+
21+
- **v1 is read-only.** No writes, no auth prompts in free tier.
22+
- **No `admin/api/*`.** Session + CSRF not a fit for native client. App is not a second admin panel.
23+
- **No push in v1.** Server contract missing.
24+
- **Excluded entirely**: `faq/create|update`, `category` POST, `backup/{type}`.
25+
- API surface pinned to phpMyFAQ public `v3.2` (`docs/openapi.yaml` upstream).
26+
27+
## Shared libs (locked)
28+
29+
Ktor · kotlinx.serialization · SQLDelight (FTS5) · Koin · openapi-generator · WorkManager/BGTaskScheduler · Coil/SDWebImageSwiftUI · StoreKit 2 / Play Billing v7+.
30+
31+
Secure storage: iOS Keychain + Android `androidx.security.crypto`. DB encrypted: SQLCipher (Android), Data Protection Class B (iOS). Credentials NEVER in SQLite.
32+
33+
## Phases
34+
35+
- **0** — foundations: CI, KMP scaffold, generated client, `Entitlements` stub returns `false`. Detailed plan: `plans/phase-0-foundations.md`.
36+
- **1** — read-only MVP online, server search, paywall shell non-functional.
37+
- **2** — offline: SQLite + FTS5 + background sync + attachments. Public v1.0.0, free tier only.
38+
- **3** — Pro: StoreKit 2 + Play Billing, login/OAuth2, ask/comment/rate, `pending_writes` queue. v2.0.0.
39+
- **4** — polish: a11y, l10n, telemetry scrub.
40+
- **5** — stretch: widgets, watch, iPad split-view, push (pending server).
41+
42+
## Pro SKUs (to finalize before Phase 3)
43+
44+
`pro_lifetime` (one-time) + `pro_annual` (~1/3 lifetime price). Tech plan supports either alone or both.
45+
46+
## Open questions (see plan §"Open questions")
47+
48+
1. Pro pricing model — before Phase 3.
49+
2. Push architecture — before Phase 5.
50+
51+
## Server-side asks (nice-to-have, non-blocking)
52+
53+
Filed as phpMyFAQ issues: tombstones (`faqs/deleted?since=`), ETag on list endpoints, OAuth discovery, push registration contract.
54+
55+
**Already shipped upstream**: `GET /api/v3.2/meta` (single bootstrap call — version, title, language, available languages, features, logo URL, OAuth discovery). App uses it for instance selector + sync bootstrap; legacy `version`+`title`+`language` fan-out kept only as fallback for older installs.
56+
57+
## Working on this repo
58+
59+
- Primary doc: `plans/mobile-app-plan.md`. Treat as source of truth for architecture questions.
60+
- Phase 0 plan: `plans/phase-0-foundations.md`.
61+
- When editing the plan: preserve "Decided" / "Open questions" structure. Do not move locked items back to open without explicit user request.
62+
- Mobile app source tree will live in this same repo (`phpMyFAQ/MyFAQ`), not in a separate `myfaq-app` repo.

docs/mobile/architecture.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Mobile Architecture
2+
3+
## Module diagram
4+
5+
```
6+
┌──────────────┐ ┌──────────────┐
7+
│ androidApp │ │ iosApp │
8+
│ (Compose) │ │ (SwiftUI) │
9+
└──────┬───────┘ └──────┬───────┘
10+
│ │
11+
└────────┬────────────┘
12+
13+
┌────────▼────────┐
14+
│ shared │
15+
│ (Kotlin/Multi- │
16+
│ platform) │
17+
└────────┬────────┘
18+
19+
┌───────────┼───────────┐
20+
│ │ │
21+
┌───▼──┐ ┌────▼───┐ ┌────▼───┐
22+
│ api/ │ │ data/ │ │ plat- │
23+
│ │ │ │ │ form/ │
24+
└──────┘ └────────┘ └────────┘
25+
```
26+
27+
## Boundaries
28+
29+
- **`shared/commonMain`** — all business logic, DTOs, API client
30+
wrapper, DAOs, `Entitlements` facade, Koin DI modules. Depends only
31+
on Kotlin stdlib, Ktor, kotlinx.serialization, SQLDelight, and Koin.
32+
33+
- **`shared/androidMain`**`actual` implementations for:
34+
- `SecureStore` (EncryptedSharedPreferences)
35+
- `DatabaseDriverFactory` (SQLCipher + AndroidSqliteDriver)
36+
- `EntitlementsPlatform` (stub; Phase 3 wires Play Billing)
37+
38+
- **`shared/iosMain`**`actual` implementations for:
39+
- `SecureStore` (Keychain Services)
40+
- `DatabaseDriverFactory` (NativeSqliteDriver + Data Protection)
41+
- `EntitlementsPlatform` (stub; Phase 3 wires StoreKit 2)
42+
43+
- **`androidApp`** — thin Jetpack Compose host. Depends on `shared`.
44+
Contains only UI code and the Android `Application` subclass that
45+
bootstraps Koin with the Android platform module.
46+
47+
- **`iosApp`** — thin SwiftUI host. Consumes the `Shared.framework`
48+
XCFramework produced by the KMP build. Contains only SwiftUI views
49+
and the `@main` App struct that bootstraps Koin.
50+
51+
## `expect` / `actual` surface
52+
53+
```
54+
expect class SecureStore { put, get, remove, clear }
55+
expect class DatabaseDriverFactory { create(passphrase) }
56+
expect object EntitlementsPlatform { isPro() }
57+
```
58+
59+
Nothing else is `expect`ed in Phase 0. Additional platform hooks
60+
(push, billing, biometrics) land in their owning phases.
61+
62+
## Data flow (Phase 0)
63+
64+
```
65+
App launch
66+
67+
initKoin (platform module + shared module)
68+
69+
MetaLoader.load()
70+
71+
MyFaqApiImpl → HttpClient (MockEngine in Phase 0)
72+
73+
Meta DTO deserialized via kotlinx.serialization
74+
75+
UI renders version string
76+
```
77+
78+
Phase 1 replaces MockEngine with real per-platform HTTP engines
79+
(OkHttp on Android, Darwin on iOS) and adds real network calls.

0 commit comments

Comments
 (0)