Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 767703f845 | |||
| 67dadb4571 | |||
| 9df5d95732 | |||
| 44451c733f | |||
| 2a79103060 | |||
| b0a577d2b2 | |||
| db839dc5c3 | |||
| 9cb72c74aa | |||
| eabfca5c75 | |||
| 1cf01cdc62 | |||
| 21444b45e7 | |||
| 8f82e578f4 | |||
| 9f8a39bb74 | |||
| 5eee7737b9 | |||
| 345a6bb8ec | |||
| 55ea61bf5a | |||
| 708535469e | |||
| 3db17825de | |||
| c8e444e2e0 | |||
| e4ca0e6208 | |||
| fd29aea9a9 | |||
| f49e5b8ac6 | |||
| 4778fe4a98 | |||
| b975611e63 | |||
| 659f3e174e |
@@ -0,0 +1,307 @@
|
||||
---
|
||||
name: releasing-healthchat
|
||||
description: Cut a HealthChat Google Play release of element-x-android by tagging release/healthchat. Use when the user asks to release, publish, tag, deploy, ship, or cut a new HealthChat Android version, asks about the Play Store release process, asks about rotating the Android release keystore, or asks about the required GitHub Actions secrets for the release pipeline.
|
||||
---
|
||||
|
||||
# Releasing HealthChat (element-x-android)
|
||||
|
||||
Releases are driven by git tags of the form `healthchat-v<version>` pushed to
|
||||
`origin`. The tag triggers `.github/workflows/healthchat-release-playstore.yml`,
|
||||
which runs on the self-hosted `novaloop-incus-large` runner, builds a signed
|
||||
AAB, and uploads it to the Play Store **internal** track. No GitHub Release is
|
||||
created.
|
||||
|
||||
**Promotion to production is always a manual step** in Play Console after an
|
||||
internal build has been validated — see "Promoting to production" below.
|
||||
|
||||
## Preflight checklist
|
||||
|
||||
Verify each item before tagging. If any fail, fix and commit first.
|
||||
|
||||
- [ ] On branch `release/healthchat` with a clean working tree (`git status`).
|
||||
- [ ] `HEALTHCHAT_CHANGES.md` has a section `## Changes in <new-version> (<date>)`
|
||||
at the top of the file. The pipeline's awk extractor matches this exact
|
||||
heading format; no match means a fallback placeholder ships as the Play
|
||||
Store "What's new" text.
|
||||
- [ ] `plugins/src/main/kotlin/Versions.kt` `versionYear`, `versionMonth`, and
|
||||
`versionReleaseNumber` produce the tag's core version (format
|
||||
`<year>.<month zero-padded to 2>.<releaseNumber>`, e.g. `26.07.1`).
|
||||
**The workflow hard-fails** if they don't match — this is load-bearing,
|
||||
not cosmetic.
|
||||
- [ ] If you edited `HEALTHCHAT_CHANGES.md` or `Versions.kt`, both commits are
|
||||
pushed to `release/healthchat` **before** you push the tag.
|
||||
|
||||
## Cutting the release (normal path)
|
||||
|
||||
```bash
|
||||
# from release/healthchat, fully committed:
|
||||
git tag healthchat-v<YEAR>.<MONTH>.<RELEASE>
|
||||
git push origin healthchat-v<YEAR>.<MONTH>.<RELEASE>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `git tag healthchat-v26.07.1` (next release in July 2026)
|
||||
- `git tag healthchat-v26.08.0` (first release in August 2026)
|
||||
- `git tag healthchat-v26.07.2-rc1` (release candidate)
|
||||
|
||||
Watch the workflow with `gh run watch` or in the GitHub Actions UI. When it
|
||||
finishes, the build appears in Play Console → Internal testing within a few
|
||||
minutes (Play Store's processing queue).
|
||||
|
||||
## Manual / retry path (workflow_dispatch)
|
||||
|
||||
Use when retrying a failed run or rebuilding without moving tags.
|
||||
|
||||
1. GitHub → Actions → "HealthChat Release to Google Play Store" → **Run workflow**.
|
||||
2. Fill in:
|
||||
- **version** (required): e.g. `26.07.1`. Same regex validation as tag path.
|
||||
Must still match `Versions.kt`.
|
||||
- **track**: default `internal`; pick `alpha`/`beta`/`production` to promote
|
||||
an already-uploaded build to a higher track without rebuilding (note:
|
||||
Play Console is usually a better UX for promotion).
|
||||
- **release_status**: default `completed`; use `draft` for dry-runs that
|
||||
land in Play Console but stay unpublished.
|
||||
- **release_notes_override** (optional): replaces the
|
||||
`HEALTHCHAT_CHANGES.md` lookup. Useful for ad-hoc test builds.
|
||||
3. Run. The AAB is built and uploaded exactly as the tag path would.
|
||||
|
||||
## Runner and build tuning
|
||||
|
||||
The job runs on `novaloop-incus-large` — an incus container with 4 cores and
|
||||
24 GiB, not a GitHub-hosted runner. Two consequences the workflow handles, and
|
||||
that anyone editing it needs to keep in mind:
|
||||
|
||||
- **The image ships no Android SDK.** The `Set up Android SDK` step installs
|
||||
it. Platform packages are minor-versioned, so `COMPILE_SDK = 37` means
|
||||
`platforms;android-37.0`. Build-tools follows AGP's own default
|
||||
(`SdkConstants.CURRENT_BUILD_TOOLS_VERSION`), not `BUILD_TOOLS_VERSION` in
|
||||
`Versions.kt` — nothing sets `buildToolsVersion`, so bump it when AGP is
|
||||
upgraded.
|
||||
- **`GRADLE_OPTS` is deliberately smaller than upstream's.** Upstream's
|
||||
workflows use `-Xmx8g` with a 4 GB Kotlin daemon, sized for the 4-core/16GB
|
||||
`ubuntu-latest` that *public* repos get; this repo is private, so its hosted
|
||||
runners are 2-core/7GB and its container is 24 GiB. At upstream's numbers the
|
||||
daemon plus the Kotlin daemon plus four parallel lint/R8 workers exceed the
|
||||
limit and the daemon is SIGKILLed — the symptom is
|
||||
`DaemonDisappearedException` with **no** `OutOfMemoryError` and no heap dump,
|
||||
which is what an external kill looks like. `-Xmx6g` with a 2 GB Kotlin daemon
|
||||
fits.
|
||||
|
||||
The `Report runner capacity` step prints `free`, `nproc`, `df` and the enforced
|
||||
cgroup limit on every run, so a future failure can be read against the runner's
|
||||
actual capacity rather than guessed at. Note `free` reports the container's
|
||||
limit only when lxcfs virtualises `/proc/meminfo`; `/sys/fs/cgroup/memory.max`
|
||||
is the number the kernel enforces.
|
||||
|
||||
## Versioning (calendar scheme — differs from the old Classic repo)
|
||||
|
||||
Element X uses calendar versioning, defined in
|
||||
`plugins/src/main/kotlin/Versions.kt`:
|
||||
|
||||
```
|
||||
versionName = <versionYear>.<versionMonth padded to 2>.<versionReleaseNumber> // e.g. 26.07.1
|
||||
versionCode = ((2000 + versionYear) * 10_000 + versionMonth * 100 + versionReleaseNumber) * 10
|
||||
```
|
||||
|
||||
The trailing `* 10` is the ABI multiplier applied per output APK
|
||||
(`app/build.gradle.kts` androidComponents block); the AAB gets ABI code `0`.
|
||||
Example: `26.07.1` → versionCode `202607010`.
|
||||
|
||||
To bump: edit `versionYear`/`versionMonth`/`versionReleaseNumber` in
|
||||
`Versions.kt` (increment `versionReleaseNumber` within a month; reset it to 0
|
||||
when the month changes). `versionCode` must be strictly monotonic in Play
|
||||
Store across all uploads — the calendar scheme guarantees this as long as you
|
||||
never decrease the triple. All HealthChat codes (≥ 202,607,000) supersede the
|
||||
old Classic app's last code (40,154,100).
|
||||
|
||||
**Keep the same version as the upstream Element X release the branch is based
|
||||
on unless HealthChat ships an intermediate build** — then bump
|
||||
`versionReleaseNumber` past upstream's. When merging a newer upstream release
|
||||
into `release/healthchat`, take upstream's `Versions.kt` if its version is
|
||||
higher.
|
||||
|
||||
## Release notes
|
||||
|
||||
`HEALTHCHAT_CHANGES.md` is the single source. The workflow extracts the
|
||||
section matching the version being released, then writes it to
|
||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and
|
||||
`fastlane/metadata/android/de-DE/changelogs/<versionCode>.txt`, which
|
||||
`r0adkll/upload-google-play` picks up via its `whatsNewDirectory` input.
|
||||
|
||||
Format:
|
||||
|
||||
```markdown
|
||||
## Changes in 26.07.1 (2026-07-20)
|
||||
|
||||
🙌 Improvements
|
||||
|
||||
- Bullet one.
|
||||
- Bullet two.
|
||||
```
|
||||
|
||||
Keep the entire section under **500 characters** — Play Store's "What's new"
|
||||
hard cap per locale. The workflow emits a warning if you exceed it; Play Store
|
||||
will truncate.
|
||||
|
||||
Never hand-edit the generated `fastlane/metadata/android/*/changelogs/<N>.txt`
|
||||
files for this branch — they'll be overwritten on the next run. Only edit
|
||||
`HEALTHCHAT_CHANGES.md`. (Older checked-in files from upstream are harmless.)
|
||||
|
||||
If no matching section exists, the pipeline emits a workflow warning and
|
||||
falls back to `HealthChat <version> — automated build from <ref>.`. Prefer
|
||||
fixing the changelog over relying on the fallback.
|
||||
|
||||
## After the release
|
||||
|
||||
- [ ] Workflow run is green in GitHub Actions.
|
||||
- [ ] Build appears under Play Console → Internal testing within ~10 minutes.
|
||||
- [ ] Internal testers get the update automatically if they're enrolled.
|
||||
- [ ] AAB + `mapping.txt` are attached to the workflow run, retained 90 days.
|
||||
|
||||
**A green "Build Release AAB" step does not mean the release shipped.** The
|
||||
upload step logs `Successfully uploaded 1 artifacts` before it commits the Play
|
||||
edit; if the commit fails, the AAB is uploaded but no release exists on the
|
||||
track. Always check the `Upload to Play Store` step, not just the job's build
|
||||
steps.
|
||||
|
||||
## Troubleshooting: `changesNotSentForReview must not be set`
|
||||
|
||||
```
|
||||
Successfully uploaded 1 artifacts
|
||||
Committing the Edit
|
||||
##[error]Changes are sent for review automatically.
|
||||
The query parameter changesNotSentForReview must not be set.
|
||||
```
|
||||
|
||||
Google flips this per app: once Play sends an app's changes for review
|
||||
automatically, the Edits API rejects `changesNotSentForReview` instead of
|
||||
ignoring it. The 26.07.0 release (2026-07-27) committed fine with the flag
|
||||
set; 26.08.0 (2026-08-03) failed with it. The parameter is no longer passed —
|
||||
if it ever needs to come back, gate it rather than setting it unconditionally.
|
||||
|
||||
Recovery: the failed edit is never committed, so the `versionCode` is not
|
||||
consumed. Re-run via `workflow_dispatch` with the same version — no tag move
|
||||
and no version bump needed.
|
||||
|
||||
## Required GitHub Actions secrets
|
||||
|
||||
The pipeline depends on these repository secrets. Missing any causes the
|
||||
workflow to fail at the corresponding step. The values are identical to the
|
||||
ones configured on the old `Novaloop-AG/element-android` repository (same
|
||||
upload keystore, same Play listing) — except `GOOGLE_SERVICES_JSON_BASE64`,
|
||||
which element-x-android does **not** use (Firebase config is compiled in via
|
||||
`libraries/pushproviders/firebase/src/main/res/values/firebase.xml`).
|
||||
|
||||
| Secret | Source |
|
||||
|---|---|
|
||||
| `RELEASE_KEYSTORE_BASE64` | `base64 -w0 release.keystore` (the HealthChat upload keystore, alias `healthchat-upload`) |
|
||||
| `RELEASE_KEYSTORE_PASSWORD` | Keystore password |
|
||||
| `RELEASE_KEY_ALIAS` | Key alias inside the keystore |
|
||||
| `RELEASE_KEY_PASSWORD` | Key password |
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Play Console → Users and permissions → invite a service account → download JSON. Needs "Release manager" permissions scoped to `ch.healthchat.element`. |
|
||||
|
||||
## Keystore rotation (rare, high-risk)
|
||||
|
||||
Unlike Apple Distribution certificates, **Android upload keystores do not
|
||||
expire** and should not be rotated casually. The upload key is tied to the
|
||||
app's identity in Play Console (Play App Signing is enabled; the repo key is
|
||||
the upload key); rotating it is a one-way process that uses Play App
|
||||
Signing's "Upload key reset" flow.
|
||||
|
||||
If the keystore is lost or believed to be compromised:
|
||||
|
||||
1. Play Console → Release → Setup → App integrity → Upload key → **Request
|
||||
upload key reset**. Instructions follow, requiring you to upload a new
|
||||
keystore and wait for Google to rotate.
|
||||
2. Generate a fresh keystore on the operator's machine:
|
||||
```bash
|
||||
keytool -genkey -v -keystore healthchat-upload-new.jks \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 -alias healthchat
|
||||
```
|
||||
3. Once Google confirms the rotation, re-encode and update secrets:
|
||||
`base64 -w0 healthchat-upload-new.jks | pbcopy`, then replace
|
||||
`RELEASE_KEYSTORE_BASE64`, `RELEASE_KEYSTORE_PASSWORD`, `RELEASE_KEY_ALIAS`,
|
||||
`RELEASE_KEY_PASSWORD` in repo settings.
|
||||
4. Run the release workflow via `workflow_dispatch` with a throwaway version
|
||||
bump and `release_status: draft` to verify end-to-end. Discard the draft.
|
||||
|
||||
**Do not attempt rotation without reading Play App Signing docs end-to-end**
|
||||
and coordinating a maintenance window. A botched rotation means users can't
|
||||
receive updates until Google completes the reset.
|
||||
|
||||
## Promoting internal → production
|
||||
|
||||
Internal builds and production builds are the same AAB. To release a
|
||||
validated internal build:
|
||||
|
||||
1. Play Console → HealthChat → Testing → **Internal testing** → find the
|
||||
release.
|
||||
2. Confirm it passed internal testing.
|
||||
3. **Promote release** → Production → **Release details** → fill in "What's
|
||||
new" (Play Console copies it from the internal release — verify) →
|
||||
**Review release** → **Start rollout to Production**.
|
||||
4. Choose staged rollout % if desired.
|
||||
|
||||
**This step is deliberately not automated.** Production releases require
|
||||
human judgment on rollout percentage, Play Store listing changes, and
|
||||
compliance metadata review.
|
||||
|
||||
## Rollback
|
||||
|
||||
- **Bad build, release notes fine** → fix the cause, bump
|
||||
`versionReleaseNumber`, commit, tag `healthchat-v<ver>`. The new build
|
||||
supersedes the bad one on the internal track.
|
||||
- **Bad release notes only** → edit `HEALTHCHAT_CHANGES.md` on
|
||||
`release/healthchat`, then update the "What's new" text in the Play Console
|
||||
release manually (the pipeline only writes it at upload time).
|
||||
- **Completely wrong version number** → treat as burned. Bump
|
||||
`versionReleaseNumber` further, tag a higher `healthchat-v<ver>`. Play
|
||||
Store's `versionCode` rule means the next run naturally supersedes.
|
||||
- **Production rollout gone wrong** → Play Console → Production → **Halt
|
||||
rollout** (works during staged rollout), then ship a hotfix as above.
|
||||
|
||||
## Known branch divergence when syncing with upstream
|
||||
|
||||
Upstream is taken by **rebasing** the HealthChat commits onto the new release
|
||||
tag, never by merging — the procedure, Git LFS handling and force-push live in
|
||||
the `syncing-upstream-healthchat` skill. The HealthChat-only facts that must
|
||||
survive every sync:
|
||||
|
||||
- **`.github/workflows/healthchat-release-playstore.yml`**,
|
||||
**`HEALTHCHAT_CHANGES.md`**, and
|
||||
**`.claude/skills/releasing-healthchat/SKILL.md`** are HealthChat-only —
|
||||
any merge conflict here means something upstream accidentally overlaps;
|
||||
keep `--ours`.
|
||||
- **`plugins/src/main/kotlin/config/BuildTimeConfig.kt`** —
|
||||
`APPLICATION_ID = "ch.healthchat.element"`, `APPLICATION_NAME = "HealthChat"`,
|
||||
`METADATA_HOST_REVERSED`, and the healthchat.ch URLs must survive merges.
|
||||
Upstream uses `io.element.android.x` / `Element X` / nulls.
|
||||
- **`appconfig/.../ApplicationConfig.kt`** — `PRODUCTION_APPLICATION_NAME` /
|
||||
`DESKTOP_APPLICATION_NAME = "HealthChat"` must survive. Upstream: `Element`.
|
||||
- **`features/enterprise/impl-foss/.../DefaultEnterpriseService.kt`** — the
|
||||
homeserver lock: `accountProviderAllowList() = listOf(Generic("matrix.healthchat.ch"))`,
|
||||
`canConnectToAnyAccountProvider() = false`, `isAllowedToConnectToAccountProvider()`
|
||||
= membership in that list (since upstream 26.10.0; before that
|
||||
`defaultHomeserverList()`). Upstream returns an empty list / `true` / `true`.
|
||||
`BuildTimeConfig.OAUTH_CLIENT_URL_PATH` must stay `null` (upstream:
|
||||
`"apps/android"`), or MAS rejects the redirect scheme.
|
||||
- **`app/build.gradle.kts`** — the `healthchat` signing config and the
|
||||
`appicon/healthchat` dependency must survive; upstream signs release with
|
||||
the debug keystore and depends on `appicon/element`.
|
||||
- **`app/src/gplay/AndroidManifest.xml`** — HealthChat-only file that strips
|
||||
`REQUEST_INSTALL_PACKAGES` and the location permissions
|
||||
(`FOREGROUND_SERVICE_LOCATION`, `ACCESS_FINE_LOCATION`,
|
||||
`ACCESS_COARSE_LOCATION`, plus `LiveLocationSharingService`) from the Play
|
||||
build. Play Console rejects the release at edit-commit time while either is
|
||||
declared but not justified on the listing. Upstream has no `app/src/gplay/`
|
||||
manifest, so this should never conflict. If HealthChat ever ships maps
|
||||
(a MapTiler key in `BuildTimeConfig`), the location removals must come out
|
||||
first — the feature is gated on that key, not on these declarations.
|
||||
- **`plugins/src/main/kotlin/Versions.kt`** — take upstream's version when
|
||||
higher (see Versioning above).
|
||||
- **`fastlane/metadata/android/`** — HealthChat listing texts (en-US, de-DE)
|
||||
must survive; upstream ships Element X texts. The per-versionCode changelog
|
||||
files generated by this pipeline carry HealthChat text; conflicts there
|
||||
mean a versionCode collided — regenerate from `HEALTHCHAT_CHANGES.md` on
|
||||
the next release run rather than hand-merging.
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: syncing-upstream-healthchat
|
||||
description: Bring the HealthChat fork (release/healthchat) onto a newer element-hq/element-x-android release tag. Use when the user asks to rebase, sync, merge or update to upstream, take a new Element X version, or asks why the fork is behind upstream. Covers the patch-stack model, the rebase procedure, Git LFS, conflicts, verification, the force-push and the rewind.
|
||||
---
|
||||
|
||||
# Syncing the HealthChat fork with upstream
|
||||
|
||||
`release/healthchat` = upstream release tag + HealthChat commits on top. Patch stack, same as
|
||||
healthchat-web and element-x-ios. A sync replaces the stack's base and force-pushes the branch.
|
||||
That is the one sanctioned history rewrite in this repo; everything else in `AGENTS.md` still
|
||||
holds. We rebase, we never merge upstream into the branch.
|
||||
|
||||
First done v26.07.0 → v26.10.0 (2026-10-16 to 10-19). Numbers below come from that run.
|
||||
|
||||
## Before touching anything
|
||||
|
||||
- [ ] **Toolchain.** Upstream pins the JDK (`java-version` in `.github/workflows/*.yml`, 21),
|
||||
`COMPILE_SDK` / `BUILD_TOOLS_VERSION` (`plugins/src/main/kotlin/Versions.kt`), AGP, Kotlin
|
||||
and Metro (`gradle/libs.versions.toml`). Gradle and AGP install themselves; the Rust SDK
|
||||
comes from Maven. What does not: the self-hosted incus runners ship no Android SDK — the
|
||||
`Set up Android SDK` step installs it (see the releasing skill, "Runner and build tuning").
|
||||
If `COMPILE_SDK` moved, check that step still resolves the platform. 26.10.0 moved nothing.
|
||||
- [ ] **Dev Mac Gradle.** A warm daemon can hang `assembleDebug` forever (0 % CPU, no log).
|
||||
`./gradlew --stop` first, then compile-only tasks with `--max-workers=2`, e.g.
|
||||
`:tests:uitests:compileDebugUnitTestKotlin` compiles every module. Gate scripts on the
|
||||
exit code — a `rebase --continue` after an unchecked compile committed a broken stop once.
|
||||
- [ ] **Upstream.** There is no upstream remote by default. Tags are `v<version>`.
|
||||
`git fetch https://github.com/element-hq/element-x-android.git tag v<new> --no-tags` is
|
||||
enough for the rebase. LFS needs a named remote:
|
||||
`git remote add upstream https://github.com/element-hq/element-x-android.git`. After
|
||||
adding it, `gh` may resolve to upstream — always pass `-R Novaloop-AG/element-x-android`.
|
||||
- [ ] **Decisions, product owner:** which upstream feature-flag defaults flipped
|
||||
(`git diff v<old> v<new> -- libraries/featureflag/api/.../FeatureFlags.kt`; 26.10.0:
|
||||
message search default on, kept on), where fork UI lands when upstream restructures a
|
||||
screen (26.10.0: settings split, HealthChat entries went to the account section), and
|
||||
whether fork commits may be squashed (only within one HealthChat release, never across).
|
||||
|
||||
## The stack
|
||||
|
||||
```bash
|
||||
git log --oneline --no-merges v<old>..release/healthchat # the fork's commits
|
||||
git merge-base --is-ancestor v<old> release/healthchat # must be true
|
||||
```
|
||||
|
||||
Upstream files the fork edits — conflicts live here every time:
|
||||
|
||||
- `features/enterprise/impl-foss/.../DefaultEnterpriseService.kt` + test — the homeserver
|
||||
lock (since 26.10.0: `accountProviderAllowList()` = `Generic("matrix.healthchat.ch")`,
|
||||
`canConnectToAnyAccountProvider() = false`, membership check).
|
||||
- `plugins/src/main/kotlin/config/BuildTimeConfig.kt` — app id, name, OIDC URLs,
|
||||
`OAUTH_CLIENT_URL_PATH = null` (MAS: redirect scheme must reverse-match the apex host).
|
||||
- `plugins/src/main/kotlin/Versions.kt` — version; dropped from the stack, set again at release.
|
||||
- `features/home/impl` (Members tab: `HomeView`, `HomePresenter`, `HomeState`,
|
||||
`HomeStatePreviewParam`, `HomeNavigationBarItem`, `HomeTopBar`, `HomeFlowNode`, tests) and
|
||||
`features/home/api/HomeEntryPoint.kt`, `appnav/.../LoggedInFlowNode.kt` (`navigateToUserProfile`).
|
||||
- `features/preferences/impl` root node/view + `PreferencesFlowNode` (profile editor replaces
|
||||
the display-name editor, account status banner).
|
||||
- `features/userprofile`, `features/roomdetails` (profile fields section).
|
||||
- `libraries/deeplink/impl`, `libraries/matrix/{api,impl}` permalink (HealthChat hosts),
|
||||
`features/login/impl/DefaultLoginIntentResolver.kt`.
|
||||
- `.github/workflows/{tests,quality,maestro-local,recordScreenshots}.yml` (self-hosted
|
||||
runners, heap, fork guards), `app/src/gplay/AndroidManifest.xml` (permission strip),
|
||||
`HEALTHCHAT_CHANGES.md`.
|
||||
|
||||
## Rebuilding the stack
|
||||
|
||||
Measure first: dry-run `git rebase --onto v<new> v<old> HEAD` in a throwaway worktree with a
|
||||
script that records the conflicting files per commit and resolves blindly, then abort. The
|
||||
conflict map decides the cleanup. 26.10.0: 11 of 31 commits stopped, 29 hunks, 62 goldens.
|
||||
|
||||
**1. Clean the series on the old base** (scratch branch off `release/healthchat`). Interactive
|
||||
rebase is not available to agents; rebuild with `cherry-pick` (`-n` + `commit -C <orig>` for
|
||||
edited commits; `cherry-pick` has no `-q`). Drop golden-only commits and the version bump;
|
||||
strip every PNG, and edits to files upstream deleted, out of feature commits; squash the CI
|
||||
run-of-commits within one release. `git diff --stat release/healthchat` must show only the
|
||||
intended removals; compile once.
|
||||
|
||||
**2. Plain rebase** — `git rebase --onto v<new> v<old> <scratch>`, resolve per stop, keep each
|
||||
commit's subject and intent. Upstream API removals cannot be "resolved", they need new code
|
||||
*inside that commit* (26.10.0: `EnterpriseService` redesign). After a large stop compile its
|
||||
modules before `--continue`. Cross-commit fallout (renames, moved fakes, signature changes)
|
||||
goes into ONE trailing `Adapt HealthChat to upstream v<new>` commit, not amended into owners —
|
||||
every amend replays the rest. A mistake in an already-committed stop: `git commit --fixup=<sha>`,
|
||||
then `GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash <sha>^` (conflict-free when later
|
||||
commits do not touch the file). Intermediates may not compile; the tip must.
|
||||
|
||||
**3. Review** — `git range-diff v<old>..release/healthchat v<new>..<scratch>`. `!` on a commit
|
||||
that applied cleanly is context drift; check the `-`/`+` lines are not ours.
|
||||
|
||||
Recurring fallout to expect: Konsist naming (`*PreviewParam` for `PreviewParameterProvider`
|
||||
subclasses, `*Event` for sealed interfaces), design-system signature changes (26.10.0:
|
||||
`ListItem(headlineContent)` → `ListItem(content)`), test fakes moving modules, action pins in
|
||||
our CI hunks regressing upstream's bumps, exhaustive `when` over `HomeNavigationBarItem`.
|
||||
|
||||
zsh: `for c in $VAR` does not split on newlines, a variable named `path` *is* `PATH`, and
|
||||
`$TAG:file` applies a history modifier — use `while read`, never name a variable `path`,
|
||||
write `${TAG}:file`.
|
||||
|
||||
## Git LFS — the part that bites
|
||||
|
||||
Goldens, compound screenshots, `docs/images-lfs`, and `libraries/mediaupload/impl` test assets
|
||||
are LFS-tracked (`.gitattributes`, ~4500 files). A fresh clone holds pointers only.
|
||||
|
||||
- origin is a standalone repo; upstream's LFS objects are not there. GitHub rejects a push
|
||||
whose *new commits* reference LFS objects it lacks (`GH008 … unknown Git LFS objects`). The
|
||||
rebased branch carries every upstream commit since the old tag. Fetch the LFS tree of each
|
||||
upstream commit that touched LFS paths, then push the objects before the branch:
|
||||
```bash
|
||||
git log --format=%H v<old>..v<new> -- '*.png' '*.jpg' '*.gif' '*.mp4' '*.m4a' '*.webp' \
|
||||
| sort -u | while read c; do git lfs fetch upstream "$c"; done
|
||||
git lfs push origin <scratch> # must report no "(missing)"; refetch that commit
|
||||
git push origin <scratch>
|
||||
```
|
||||
26.10.0: 210 such commits, 9837 objects, 525 MB locally, 3809 uploaded (174 MB). Run the loop
|
||||
detached (`nohup`) — an agent's tool call times out at 10 minutes — and keep a done-file so it
|
||||
resumes. Objects still "(missing)" after the loop are usually goldens from the fork's *own*
|
||||
commits that a pointer-only clone never held: `git lfs fetch origin <original commit sha>`
|
||||
for each commit on the old branch that touched them. The dry run lists every object until
|
||||
the branch itself is pushed; verify presence with the LFS batch API or trust a clean upload.
|
||||
- `git lfs push <branch>` only covers objects introduced by commits absent from the remote. 26.10.0:
|
||||
167 objects referenced by the v26.10.0 tree were outside that set and `Validate Git LFS` failed
|
||||
with "Object does not exist on the server". Read the oids from that job's log and
|
||||
`git lfs push --object-id origin <oid…>` them (they are local after the fetch loop). Treat a
|
||||
green `Validate Git LFS` on the PR as the only proof the store is complete.
|
||||
- The recorder commits as ElementBot, a non-member: its CI runs land in `action_required` and
|
||||
need `gh api -X POST repos/<repo>/actions/runs/<id>/approve` (or the PR's "Approve and run").
|
||||
Never push to the branch while a recording runs: the recorder's final `git push` is a plain
|
||||
push and is rejected once the tip has moved.
|
||||
- `git lfs checkout` before running `libraries/mediaupload/impl` tests locally; pointer files
|
||||
fail them. The screenshot tests are never run locally (`AGENTS.md`).
|
||||
- `validate-lfs.yml` fails a PR that commits a PNG outside LFS.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Take upstream's goldens on every conflict and never commit locally rendered ones (macOS
|
||||
renders differ). After the push, add the `Record-Screenshots` label to the PR or
|
||||
`workflow_dispatch` `recordScreenshots.yml`; the Linux runner records and commits all goldens.
|
||||
Review only the HealthChat ones. A PR whose base is `release/healthchat` is *conflicting* by
|
||||
construction (old and new stack touch the same files) and GitHub runs no `pull_request`
|
||||
workflow on a conflicting PR — open the PR against a branch at the upstream tag
|
||||
(`upstream/v<new>`, pushed by the owner) so the PR shows the 19-odd stack commits and CI runs.
|
||||
Retargeting a PR is an `edited` event: tests/quality/build only start on the next push.
|
||||
The label path needs `persist-credentials: true` on the LFS checkout steps (private fork,
|
||||
fixed in 26.10.0). If the job dies on the LayoutLib static-initialiser crash,
|
||||
restore the "one JVM per screenshot test class" workaround (`forkEvery = 1L` in the root
|
||||
`build.gradle.kts`, dropped in 26.10.0 because upstream's Paparazzi bump fixed it).
|
||||
|
||||
## Verification of the tip
|
||||
|
||||
Local, all green before handover (26.10.0: 1200 unit tests):
|
||||
```bash
|
||||
./gradlew :tests:uitests:compileDebugUnitTestKotlin :app:compileGplayDebugKotlin
|
||||
./gradlew :features:healthchat:impl:testDebugUnitTest :features:home:impl:testDebugUnitTest \
|
||||
:features:preferences:impl:testDebugUnitTest :features:enterprise:impl-foss:testDebugUnitTest \
|
||||
:features:login:impl:testDebugUnitTest :libraries:matrix:impl:testDebugUnitTest :appnav:testDebugUnitTest
|
||||
./gradlew :tests:konsist:test ktlintCheck detekt lint
|
||||
```
|
||||
CI on the branch (tests, quality, screenshots) must be green on the self-hosted runners.
|
||||
|
||||
Device matrix (owner, debug and release build): MAS OIDC login on a fresh install — chooser
|
||||
skipped, client URI `https://healthchat.ch` without appended path — is the gate for everything
|
||||
else; previous release → new keeps the session; last Classic build → new lands on login without
|
||||
crashing; member directory, profile editor, invites, status banners; deeplinks on HealthChat
|
||||
hosts and `to.healthchat.ch` share links; push in background and killed; release build under
|
||||
R8 exercising the suspend Retrofit calls; whatever upstream switched on (26.10.0: German
|
||||
whole-word search in an encrypted room).
|
||||
|
||||
## Force-push, rewind
|
||||
|
||||
```bash
|
||||
git push origin release/healthchat:refs/heads/backup/release-healthchat-before-<new>
|
||||
git push --force-with-lease=release/healthchat:<old tip> origin <scratch>:release/healthchat
|
||||
git checkout release/healthchat && git reset --hard origin/release/healthchat # every clone
|
||||
```
|
||||
|
||||
The branch is unprotected: the push lands immediately. Old `healthchat-v*` tags keep their
|
||||
commits alive and off the branch line. Rewind =
|
||||
`git push --force origin healthchat-v<last>^{commit}:release/healthchat`. A rewind does not
|
||||
remove a tag or Play build made on the new history — tag only after the device tests. Then set
|
||||
the version, update `HEALTHCHAT_CHANGES.md`, the releasing skill ("Known branch divergence")
|
||||
and this skill with what changed.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Merge upstream into `release/healthchat`, or squash the fork into a side branch and merge
|
||||
upstream there: both duplicate every fork commit and conflict on every touched file.
|
||||
- Pull on a rewritten branch (merges the old history back in). Fetch and reset.
|
||||
- Commit goldens from a Mac, or resolve a golden conflict by hand — take upstream's and record.
|
||||
- Amend compile fixes into owner commits mid-rebase; collect them in the adaptation commit.
|
||||
- Touch `release/healthchat` before the owner has reviewed the range-diff.
|
||||
@@ -7,18 +7,32 @@ on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build APKs
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
permissions:
|
||||
# A job-level block replaces the workflow-level one, so contents:read has to be
|
||||
# repeated here or checkout fails on this private fork.
|
||||
contents: read
|
||||
# For NejcZdovc/comment-pr
|
||||
pull-requests: write
|
||||
strategy:
|
||||
@@ -31,6 +45,9 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
|
||||
@@ -7,17 +7,28 @@ on:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Enterprise APKs
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
strategy:
|
||||
@@ -30,6 +41,9 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
name: HealthChat Release to Google Play Store
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'healthchat-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Marketing version (e.g. 26.07.0). Required when running manually.'
|
||||
required: true
|
||||
type: string
|
||||
track:
|
||||
description: 'Play Store track'
|
||||
required: true
|
||||
default: 'internal'
|
||||
type: choice
|
||||
options:
|
||||
- internal
|
||||
- alpha
|
||||
- beta
|
||||
- production
|
||||
release_status:
|
||||
description: 'Release status'
|
||||
required: true
|
||||
default: 'completed'
|
||||
type: choice
|
||||
options:
|
||||
- completed
|
||||
- inProgress
|
||||
- halted
|
||||
- draft
|
||||
release_notes_override:
|
||||
description: 'Optional release notes. If empty, falls back to HEALTHCHAT_CHANGES.md section for the given version.'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
# Only need read access to clone. Upload uses the Play Console service
|
||||
# account JSON from secrets, not GITHUB_TOKEN.
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: healthchat-release-${{ github.event.inputs.version || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# Upstream's workflows hardcode -Xmx8g / Kotlin daemon -Xmx4g, sized for the
|
||||
# 4-core/16GB ubuntu-latest that public repos get. On the previous incus
|
||||
# container the daemon kept getting SIGKILLed mid-build
|
||||
# (DaemonDisappearedException, no OutOfMemoryError, no heap dump), so these
|
||||
# values stay well under upstream's ceiling and leave room for the Kotlin
|
||||
# daemon and the parallel lint workers. Re-check them against the "Report
|
||||
# runner capacity" output now that jobs land on the persistent org runners,
|
||||
# whose specs may differ.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and upload AAB to Play Store
|
||||
# Persistent org runners (github-runner-01, gha-runner-1..3). The previous
|
||||
# novaloop-incus-large label pointed at the GARM ephemeral pool, whose freshly
|
||||
# spawned runners were reaped mid-job — jobs died ~10 minutes in with
|
||||
# "The operation was canceled" and no other diagnostic.
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
# Tag pushes must originate from release/healthchat. Dispatch runs must be
|
||||
# invoked from that branch too. Reject anything else up-front so we don't
|
||||
# ship off main/develop.
|
||||
- name: Verify ref is on release/healthchat
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# On tag pushes, GITHUB_REF_NAME is the tag, not the branch. Resolve
|
||||
# the commit's branch membership instead.
|
||||
if ! git branch -r --contains "$GITHUB_SHA" | grep -qE 'origin/release/healthchat$'; then
|
||||
echo "::error::Commit $GITHUB_SHA is not on origin/release/healthchat. Refusing to release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve marketing version
|
||||
id: version
|
||||
env:
|
||||
# Passed through the environment, never expanded into the script (zizmor: template-injection).
|
||||
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||||
VERSION="$INPUT_VERSION"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME#healthchat-v}"
|
||||
fi
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Invalid marketing version '$VERSION' — expected e.g. 26.07.0"
|
||||
exit 1
|
||||
fi
|
||||
# Drop any pre-release/build suffix (e.g. -rc1, -hotfix1) for the Versions.kt comparison.
|
||||
BASE_VERSION=$(echo "$VERSION" | sed -E 's/([0-9]+\.[0-9]+\.[0-9]+)([.-].*)?$/\1/')
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved marketing version: $VERSION (base: $BASE_VERSION)"
|
||||
|
||||
- name: Verify Versions.kt matches tag
|
||||
id: gradle_version
|
||||
env:
|
||||
BASE_VERSION: ${{ steps.version.outputs.base_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSIONS_FILE=plugins/src/main/kotlin/Versions.kt
|
||||
YEAR=$(grep -E 'private const val versionYear' "$VERSIONS_FILE" | sed -E 's/.*=\s*([0-9]+).*/\1/')
|
||||
MONTH=$(grep -E 'private const val versionMonth' "$VERSIONS_FILE" | sed -E 's/.*=\s*([0-9]+).*/\1/')
|
||||
REL=$(grep -E 'private const val versionReleaseNumber' "$VERSIONS_FILE" | sed -E 's/.*=\s*([0-9]+).*/\1/')
|
||||
if [[ -z "$YEAR" || -z "$MONTH" || -z "$REL" ]]; then
|
||||
echo "::error::Could not parse versionYear/versionMonth/versionReleaseNumber from $VERSIONS_FILE"
|
||||
exit 1
|
||||
fi
|
||||
# VERSION_NAME format (see Versions.kt): year on 2 digits, month zero-padded to 2, release number unpadded.
|
||||
GRADLE_VERSION="${YEAR}.$(printf '%02d' "$MONTH").${REL}"
|
||||
echo "Parsed $VERSIONS_FILE: $GRADLE_VERSION"
|
||||
if [[ "$GRADLE_VERSION" != "$BASE_VERSION" ]]; then
|
||||
echo "::error::Tag version $BASE_VERSION does not match Versions.kt ($GRADLE_VERSION). Bump versionYear/versionMonth/versionReleaseNumber and commit before tagging."
|
||||
exit 1
|
||||
fi
|
||||
# Formula (see Versions.kt VERSION_CODE): (2000+year)*10_000 + month*100 + releaseNumber,
|
||||
# then *10 for the AAB output (ABI code 0 — see app/build.gradle.kts androidComponents block).
|
||||
BASE_CODE=$(( (2000 + YEAR) * 10000 + MONTH * 100 + REL ))
|
||||
VERSION_CODE=$(( BASE_CODE * 10 ))
|
||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed versionCode: $VERSION_CODE"
|
||||
|
||||
- name: Extract release notes
|
||||
id: notes
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.base_version }}
|
||||
OVERRIDE: ${{ github.event.inputs.release_notes_override }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${OVERRIDE:-}" ]]; then
|
||||
NOTES="$OVERRIDE"
|
||||
echo "Using release notes from workflow input"
|
||||
else
|
||||
NOTES=$(awk -v ver="$VERSION" '
|
||||
/^## Changes in / {
|
||||
if (found) { exit }
|
||||
if ($0 ~ "## Changes in " ver "( |$|\\()") { found = 1; next }
|
||||
}
|
||||
found { print }
|
||||
' HEALTHCHAT_CHANGES.md | sed '/^[[:space:]]*$/{N;/^\n$/D;}')
|
||||
if [[ -z "$NOTES" ]]; then
|
||||
NOTES="HealthChat ${VERSION} — automated build from ${GITHUB_REF_NAME}."
|
||||
echo "::warning::No HEALTHCHAT_CHANGES.md section found for $VERSION — using fallback"
|
||||
fi
|
||||
fi
|
||||
# Play Store "What's new" has a 500-character limit per locale.
|
||||
CHAR_COUNT=$(printf '%s' "$NOTES" | wc -c)
|
||||
if (( CHAR_COUNT > 500 )); then
|
||||
echo "::warning::Release notes are $CHAR_COUNT chars; Play Store truncates at 500"
|
||||
fi
|
||||
{
|
||||
echo 'notes<<NOTES_EOF'
|
||||
echo "$NOTES"
|
||||
echo 'NOTES_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate fastlane changelog files
|
||||
env:
|
||||
VERSION_CODE: ${{ steps.gradle_version.outputs.version_code }}
|
||||
NOTES: ${{ steps.notes.outputs.notes }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for locale in en-US de-DE; do
|
||||
dir="fastlane/metadata/android/${locale}/changelogs"
|
||||
mkdir -p "$dir"
|
||||
printf '%s\n' "$NOTES" > "${dir}/${VERSION_CODE}.txt"
|
||||
echo "Wrote ${dir}/${VERSION_CODE}.txt"
|
||||
done
|
||||
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21'
|
||||
|
||||
# The Android SDK now comes from the self-hosted runner image, so there is no
|
||||
# setup-android step here. The image must provide it AND export ANDROID_HOME to
|
||||
# the job environment — Gradle fails with "SDK location not found" otherwise.
|
||||
# The image also has to carry the platform matching COMPILE_SDK in
|
||||
# plugins/src/main/kotlin/Versions.kt (platform packages are minor-versioned, so
|
||||
# compileSdk 37 means android-37.0) and the build-tools version AGP defaults to
|
||||
# (SdkConstants.CURRENT_BUILD_TOOLS_VERSION), since nothing sets buildToolsVersion
|
||||
# here. Both need refreshing in the image when AGP or COMPILE_SDK moves.
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@748248ddd2a24f49513d8f472f81c3a07d4d50e1 # v4.4.4
|
||||
with:
|
||||
# Release builds must not consume a cache another workflow could have poisoned.
|
||||
cache-disabled: true
|
||||
|
||||
- name: Decode keystore
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > app/signature/release.keystore
|
||||
|
||||
# Recorded on every run so build failures can be read against the
|
||||
# runner's actual capacity instead of guessed at.
|
||||
- name: Report runner capacity
|
||||
run: |
|
||||
free -h
|
||||
nproc
|
||||
df -h .
|
||||
# free reads /proc/meminfo, which in a container may report the host's
|
||||
# total rather than the enforced limit. This is what the kernel enforces.
|
||||
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|
||||
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|
||||
|| echo "no cgroup memory limit visible"
|
||||
|
||||
- name: Build Release AAB
|
||||
run: |
|
||||
./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
|
||||
env:
|
||||
RELEASE_STORE_FILE: signature/release.keystore
|
||||
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||
|
||||
- name: Upload workflow artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: healthchat-${{ steps.version.outputs.version }}
|
||||
path: |
|
||||
app/build/outputs/bundle/gplayRelease/*.aab
|
||||
app/build/outputs/mapping/gplayRelease/mapping.txt
|
||||
retention-days: 90
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload to Play Store
|
||||
uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: ch.healthchat.element
|
||||
releaseFiles: app/build/outputs/bundle/gplayRelease/*.aab
|
||||
tracks: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.track || 'internal' }}
|
||||
status: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_status || 'completed' }}
|
||||
whatsNewDirectory: fastlane/metadata/android
|
||||
mappingFile: app/build/outputs/mapping/gplayRelease/mapping.txt
|
||||
# Do NOT set changesNotSentForReview. Play now sends this app's changes for review
|
||||
# automatically, and the Edits API rejects the parameter outright when it does:
|
||||
# "Changes are sent for review automatically. The query parameter
|
||||
# changesNotSentForReview must not be set." The AAB uploads fine and then the commit
|
||||
# fails, so the release silently never lands on the track. See SKILL.md.
|
||||
@@ -5,11 +5,22 @@ on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
ARCH: x86_64
|
||||
DEVICE: pixel_7_pro
|
||||
@@ -19,7 +30,7 @@ env:
|
||||
jobs:
|
||||
build-apk:
|
||||
name: Build APK
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Secrets are not available to pull_request runs from forks, so the Maestro job below
|
||||
# cannot run for them; skip the build too, as its only consumer is that job.
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
@@ -28,6 +39,9 @@ jobs:
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
|
||||
@@ -10,6 +10,8 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
pr-checks:
|
||||
# Upstream-only: this fork does not use the PR- label convention.
|
||||
if: github.repository == 'element-hq/element-x-android'
|
||||
name: PR Checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
@@ -23,33 +23,38 @@ jobs:
|
||||
script: |
|
||||
core.setFailed("PR has been labeled with X-Blocked; it cannot be merged.");
|
||||
|
||||
community-prs:
|
||||
name: Label Community PRs
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.action == 'opened'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check membership
|
||||
if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||
uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
|
||||
id: teams
|
||||
with:
|
||||
username: ${{ github.event.pull_request.user.login }}
|
||||
organization: element-hq
|
||||
team: Vector Core
|
||||
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
|
||||
- name: Add label
|
||||
if: steps.teams.outputs.isTeamMember == 'false'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.addLabels({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
labels: ['Z-Community-PR']
|
||||
});
|
||||
# Disabled on the HealthChat fork. This job asks whether the PR author belongs to the
|
||||
# element-hq "Vector Core" team, using secrets.ELEMENT_BOT_TOKEN_READ_ORG — a secret
|
||||
# that only exists upstream. Here it fails on every PR that is opened, and there is no
|
||||
# community to label anyway. Kept commented rather than deleted so upstream changes to
|
||||
# it still conflict visibly on merge.
|
||||
# community-prs:
|
||||
# name: Label Community PRs
|
||||
# runs-on: ubuntu-latest
|
||||
# if: github.event.action == 'opened'
|
||||
# permissions:
|
||||
# pull-requests: write
|
||||
# steps:
|
||||
# - name: Check membership
|
||||
# if: github.event.pull_request.user.login != 'renovate[bot]'
|
||||
# uses: tspascoal/get-user-teams-membership@b2546c5affc730fd8e3d8483ae9ad3621938c2f9 # v4.0.2
|
||||
# id: teams
|
||||
# with:
|
||||
# username: ${{ github.event.pull_request.user.login }}
|
||||
# organization: element-hq
|
||||
# team: Vector Core
|
||||
# GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN_READ_ORG }}
|
||||
# - name: Add label
|
||||
# if: steps.teams.outputs.isTeamMember == 'false'
|
||||
# uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# with:
|
||||
# script: |
|
||||
# github.rest.issues.addLabels({
|
||||
# issue_number: context.issue.number,
|
||||
# owner: context.repo.owner,
|
||||
# repo: context.repo.repo,
|
||||
# labels: ['Z-Community-PR']
|
||||
# });
|
||||
|
||||
close-if-fork-develop:
|
||||
name: Forbid develop branch fork contributions
|
||||
|
||||
@@ -7,11 +7,22 @@ on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
@@ -20,6 +31,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
@@ -63,7 +77,7 @@ jobs:
|
||||
|
||||
checkDependencies:
|
||||
name: Search for invalid dependencies
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -87,7 +101,7 @@ jobs:
|
||||
# Code checks
|
||||
konsist:
|
||||
name: Konsist tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }}
|
||||
@@ -128,12 +142,21 @@ jobs:
|
||||
|
||||
compose:
|
||||
name: Compose tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-compose-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-compose-develop-{0}', github.sha) || format('check-compose-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
# Records the container's real limits so heap settings can be read against them
|
||||
# instead of guessed at. Mirrors healthchat-release-playstore.yml.
|
||||
- name: Report runner capacity
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
free -h; nproc; df -h .
|
||||
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|
||||
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|
||||
|| echo "no cgroup memory limit visible"
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
@@ -162,7 +185,7 @@ jobs:
|
||||
|
||||
lint:
|
||||
name: Android lint check
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }}
|
||||
@@ -207,7 +230,7 @@ jobs:
|
||||
|
||||
detekt:
|
||||
name: Detekt checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }}
|
||||
@@ -248,7 +271,7 @@ jobs:
|
||||
|
||||
ktlint:
|
||||
name: Ktlint checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }}
|
||||
@@ -331,6 +354,10 @@ jobs:
|
||||
name: Run zizmor
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# A job-level block replaces the workflow-level one, so contents:read has to be
|
||||
# repeated here or checkout fails on this private fork.
|
||||
contents: read
|
||||
actions: read # Required for zizmor to read workflow files.
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -5,12 +5,27 @@ on:
|
||||
pull_request:
|
||||
types: [ labeled ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g -Dsonar.gradle.skipCompile=true
|
||||
CI_GRADLE_ARG_PROPERTIES: --no-configuration-cache
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
# Kept character-identical to tests.yml so the record path and the verify path cannot drift
|
||||
# apart again. These only affect the Gradle daemon — Paparazzi renders in forked test workers
|
||||
# configured entirely by build.gradle.kts — but keeping them aligned removes a whole class of
|
||||
# "did record and verify run the same way?" questions.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache --max-workers=4
|
||||
|
||||
jobs:
|
||||
record:
|
||||
@@ -19,11 +34,14 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
name: Record screenshots on branch ${{ github.event.pull_request.head.ref || github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Record-Screenshots'
|
||||
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
@@ -42,17 +60,21 @@ jobs:
|
||||
uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1.3.0
|
||||
with:
|
||||
labels: Record-Screenshots
|
||||
# Private fork: the action checks out and then runs `git lfs pull` on a cache miss. Upstream
|
||||
# can do that anonymously because it is public; here the pull needs the job token, so the
|
||||
# credentials must stay configured. Without this the step dies with
|
||||
# `could not read Username for 'https://github.com'`.
|
||||
- name: ⏬ Checkout with LFS (PR)
|
||||
if: github.event.label.name == 'Record-Screenshots'
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 zizmor: ignore[artipacked]
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
- name: ⏬ Checkout with LFS (Branch)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5 zizmor: ignore[artipacked]
|
||||
with:
|
||||
persist-credentials: false
|
||||
persist-credentials: true
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
|
||||
with:
|
||||
@@ -68,5 +90,9 @@ jobs:
|
||||
run: ./.github/workflows/scripts/recordScreenshots.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
|
||||
# Must be the `github.repository` context, not a secret. There is no
|
||||
# GITHUB_REPOSITORY secret on this fork, so `secrets.GITHUB_REPOSITORY` expands to ""
|
||||
# and overrides the built-in env var of the same name — recordScreenshots.sh then
|
||||
# exits 1 with "No repo specified" before Gradle ever runs.
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GRADLE_ARGS: ${{ env.CI_GRADLE_ARG_PROPERTIES }}
|
||||
|
||||
@@ -7,24 +7,38 @@ on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx2g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --warn -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
GROUP: ${{ format('sonar-{0}', github.ref) }}
|
||||
|
||||
jobs:
|
||||
sonar:
|
||||
name: Sonar Quality Checks
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ format('sonar-{0}', github.ref) }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
|
||||
@@ -7,24 +7,53 @@ on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
# Enrich gradle.properties for CI/CD
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
# Reduced from upstream's -Xmx8g / 4 GB Kotlin daemon for the self-hosted runners.
|
||||
# Each incus container reports 48 GiB and 8 cores, so a single build has ample room —
|
||||
# but the containers are oversubscribed against their host, and two concurrent heavy
|
||||
# Gradle jobs get their daemons SIGKILLed ("the daemon has disappeared", no
|
||||
# OutOfMemoryError, no heap dump). Running the same job alone succeeds. Lower heaps
|
||||
# make two builds likelier to coexist; the real fix is sizing the containers to the
|
||||
# host. See the "Report runner capacity" step.
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options=-Xmx2g -XX:+UseG1GC
|
||||
# --max-workers caps how many test tasks run at once. build.gradle.kts sets
|
||||
# maxParallelForks = cores/2 and screenshot tests fork at maxHeapSize=2g, so on these
|
||||
# 8-core containers the default (workers = cores = 8) allows 8 x 4 x 2g = 64 GB of test
|
||||
# JVMs against a 48 GiB cgroup limit, with no swap — the daemon gets OOM-killed and
|
||||
# Gradle reports "the daemon has disappeared". GitHub's 4-core hosted runners reach only
|
||||
# 2 x 2 x 2g = 16 GB and additionally have the 8 GB swapfile added above.
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache --max-workers=4
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Runs unit tests
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, Linux, X64]
|
||||
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('unit-tests-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
# Records the container's real limits so heap settings can be read against them
|
||||
# instead of guessed at. Mirrors healthchat-release-playstore.yml.
|
||||
- name: Report runner capacity
|
||||
if: runner.environment == 'self-hosted'
|
||||
run: |
|
||||
free -h; nproc; df -h .
|
||||
cat /sys/fs/cgroup/memory.max 2>/dev/null \
|
||||
|| cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null \
|
||||
|| echo "no cgroup memory limit visible"
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
# Only meaningful on GitHub's throwaway VMs. On a persistent self-hosted runner this
|
||||
# would delete dotnet/haskell/docker images from the host on every run.
|
||||
if: runner.environment == 'github-hosted'
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
@@ -41,6 +70,9 @@ jobs:
|
||||
# Increase swapfile size to prevent screenshot tests getting terminated
|
||||
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
|
||||
- name: 💽 Increase swapfile size
|
||||
# /mnt/swapfile is a GitHub runner-image path and this needs passwordless sudo,
|
||||
# so it only applies to the hosted VMs.
|
||||
if: runner.environment == 'github-hosted'
|
||||
run: |
|
||||
sudo swapoff -a
|
||||
sudo fallocate -l 8G /mnt/swapfile
|
||||
@@ -112,7 +144,14 @@ jobs:
|
||||
echo "---" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# https://github.com/codecov/codecov-action
|
||||
# This fork has no CODECOV_TOKEN, and Codecov rejects tokenless uploads from private
|
||||
# repositories ("Token required - not valid tokenless upload"). With
|
||||
# fail_ci_if_error: true that failed the whole job *after* the tests had passed, so
|
||||
# skip the upload unless a token is configured. Adding the secret re-enables it.
|
||||
- name: ☂️ Upload coverage reports to codecov
|
||||
if: env.CODECOV_TOKEN != ''
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
|
||||
@@ -2,7 +2,11 @@ name: Validate Git LFS
|
||||
|
||||
on: [pull_request, merge_group]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
# Private fork: actions/checkout needs contents:read to clone. Upstream is public,
|
||||
# where an empty token can still read the repo, so an empty permissions block works there and
|
||||
# fails here with `fatal: repository not found`.
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -79,6 +79,9 @@ captures/
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# HealthChat upload keystore, materialized from a CI secret — never commit it
|
||||
app/signature/release.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# HealthChat Android changelog
|
||||
|
||||
This file is the single source for Play Store "What's new" texts. The release
|
||||
pipeline (`.github/workflows/healthchat-release-playstore.yml`) extracts the
|
||||
top section matching the released version — the heading must have the exact
|
||||
form `## Changes in <version> (<date>)` and the whole section must stay under
|
||||
500 characters. See `.claude/skills/releasing-healthchat/SKILL.md`.
|
||||
|
||||
## Changes in 26.10.0 (2026-10-20)
|
||||
|
||||
🎉 Neue Funktionen
|
||||
|
||||
- Nachrichtensuche: Chats durchsuchen, auch in verschlüsselten Räumen.
|
||||
- Umfragen mit Mehrfachauswahl.
|
||||
|
||||
🙌 Verbesserungen
|
||||
|
||||
- Basis aktualisiert auf Element X 26.10.0 mit zahlreichen Fehlerbehebungen.
|
||||
- Einstellungen neu gegliedert in Konto- und App-Einstellungen.
|
||||
|
||||
## Changes in 26.08.0 (2026-08-06)
|
||||
|
||||
🎉 Neue Funktionen
|
||||
|
||||
- Mitgliederverzeichnis: Alle Mitglieder durchsuchen und direkt einen Chat starten.
|
||||
- Profil bearbeiten: Angaben und Profilbild direkt in der App pflegen.
|
||||
- Neue Mitglieder per E-Mail einladen.
|
||||
- Statushinweise zeigen den Stand der Kontofreigabe.
|
||||
|
||||
🙌 Verbesserungen
|
||||
|
||||
- HealthChat-Links öffnen sich neu direkt in der App statt im Browser.
|
||||
- Geteilte Profillinks verweisen neu auf HealthChat statt auf Element.
|
||||
|
||||
## Changes in 26.07.0 (2026-07-16)
|
||||
|
||||
🎉 Komplett neue App-Generation
|
||||
|
||||
- HealthChat basiert neu auf Element X: schnellere Synchronisation, moderne Oberfläche und verbesserte Verschlüsselung.
|
||||
- Anmeldung neu über den sicheren HealthChat Login (SSO).
|
||||
- Hinweis: Nach dem Update ist eine einmalige Neuanmeldung erforderlich.
|
||||
@@ -98,6 +98,18 @@ android {
|
||||
)
|
||||
storePassword = environmentOrGradleValue("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD", "signing.element.nightly.storePassword")
|
||||
}
|
||||
// HealthChat Play Store upload key. The keystore is not in the repo:
|
||||
// CI decodes it from the RELEASE_KEYSTORE_BASE64 secret (see
|
||||
// .github/workflows/healthchat-release-playstore.yml).
|
||||
register("healthchat") {
|
||||
keyAlias = System.getenv("RELEASE_KEY_ALIAS")
|
||||
?: project.property("signing.healthchat.keyId") as? String?
|
||||
keyPassword = System.getenv("RELEASE_KEY_PASSWORD")
|
||||
?: project.property("signing.healthchat.keyPassword") as? String?
|
||||
storeFile = file(System.getenv("RELEASE_STORE_FILE") ?: "./signature/release.keystore")
|
||||
storePassword = System.getenv("RELEASE_STORE_PASSWORD")
|
||||
?: project.property("signing.healthchat.storePassword") as? String?
|
||||
}
|
||||
}
|
||||
|
||||
val baseAppName = BuildTimeConfig.APPLICATION_NAME
|
||||
@@ -124,7 +136,13 @@ android {
|
||||
"login_redirect_scheme",
|
||||
oAuthRedirectSchemeBase,
|
||||
)
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Sign with the HealthChat upload key when it is available (CI),
|
||||
// fall back to the debug key for local release builds.
|
||||
signingConfig = if (System.getenv("RELEASE_STORE_FILE") != null || file("./signature/release.keystore").exists()) {
|
||||
signingConfigs.getByName("healthchat")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
|
||||
optimization {
|
||||
enable = true
|
||||
@@ -274,7 +292,7 @@ dependencies {
|
||||
implementation(projects.appicon.enterprise)
|
||||
} else {
|
||||
implementation(projects.features.enterprise.implFoss)
|
||||
implementation(projects.appicon.element)
|
||||
implementation(projects.appicon.healthchat)
|
||||
}
|
||||
allFeaturesImpl(project)
|
||||
implementation(projects.features.migration.api)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2026 Novaloop AG
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!--
|
||||
HealthChat does not want APK sideloading from chat. Upstream declares
|
||||
REQUEST_INSTALL_PACKAGES so that tapping an .apk attachment in the media
|
||||
viewer deep-links to "Install unknown apps" and hands the file to the
|
||||
system installer (see AndroidLocalMediaActions.open) — a workflow aimed at
|
||||
Element's nightly/F-Droid audience, and not one we want in a healthcare
|
||||
messenger. Play Console also rejects releases carrying this permission
|
||||
unless it is declared and justified on the listing.
|
||||
|
||||
Removing it is a configuration upstream supports: the media viewer checks
|
||||
for the permission before using it and otherwise falls through to the
|
||||
normal system open path, so .apk attachments still open, just without the
|
||||
in-app install shortcut.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||
tools:node="remove" />
|
||||
|
||||
<!--
|
||||
HealthChat ships no location features, so it should not ask for location.
|
||||
They are already unreachable at runtime: DefaultLocationService gates the
|
||||
whole feature on BuildConfig.MAPTILER_API_KEY, and HealthChat sets no
|
||||
MapTiler key (BuildTimeConfig.SERVICES_MAPTILER_APIKEY = null, and the
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY fallback is unset in CI). The composer's
|
||||
"Share location" action and the location viewer are both hidden as a
|
||||
result, so LiveLocationSharingService can never start — only these
|
||||
declarations survive, and FOREGROUND_SERVICE_LOCATION forces a foreground
|
||||
service declaration on the Play listing for a feature we do not ship.
|
||||
|
||||
Removing the declarations changes nothing users can reach, and keeps the
|
||||
location module in the dependency graph so upstream merges stay clean.
|
||||
|
||||
The service must be named fully qualified: the library declares it
|
||||
relative to its own namespace (io.element.android.features.location.impl),
|
||||
whereas a leading dot here would resolve against the app's namespace.
|
||||
|
||||
FOREGROUND_SERVICE itself stays — the call and push modules need it.
|
||||
-->
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_LOCATION"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
tools:node="remove" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name="io.element.android.features.location.impl.live.service.LiveLocationSharingService"
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -49,7 +49,7 @@
|
||||
Using launchMode singleTask to avoid multiple instances of the Activity
|
||||
when the app is already open. This is important for incoming share (see
|
||||
https://github.com/element-hq/element-x-android/issues/4074) and for opening
|
||||
the application from a mobile.element.io link.
|
||||
the application from a permalink.
|
||||
-->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -70,7 +70,7 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data
|
||||
android:host="open"
|
||||
android:scheme="elementx" />
|
||||
android:scheme="healthchat" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
OAuth redirection
|
||||
@@ -84,7 +84,9 @@
|
||||
<data android:scheme="@string/login_redirect_scheme" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Element web links
|
||||
HealthChat web links
|
||||
Note: hosts are listed one by one on purpose, a "*.healthchat.ch" wildcard would
|
||||
also intercept the homeserver and authentication domains.
|
||||
-->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -93,28 +95,12 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<!-- Note: we can't use "*.element.io" here because it'll intercept the "mas.element.io" domain too. -->
|
||||
<!-- Matching asset file: https://app.element.io/.well-known/assetlinks.json -->
|
||||
<data android:host="app.element.io" />
|
||||
<!-- Matching asset file: https://develop.element.io/.well-known/assetlinks.json -->
|
||||
<data android:host="develop.element.io" />
|
||||
<!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json -->
|
||||
<data android:host="staging.element.io" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
Element mobile links
|
||||
Example: https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org
|
||||
-->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="https" />
|
||||
<!-- Matching asset file: https://mobile.element.io/.well-known/assetlinks.json -->
|
||||
<data android:host="mobile.element.io" />
|
||||
<data android:path="/element/" />
|
||||
<!-- HealthChat web client permalinks, example: https://app.healthchat.ch/#/room/#room:healthchat.ch -->
|
||||
<!-- Matching asset file: https://app.healthchat.ch/.well-known/assetlinks.json -->
|
||||
<data android:host="app.healthchat.ch" />
|
||||
<!-- HealthChat matrix.to deployment, example: https://to.healthchat.ch/#/#room:healthchat.ch -->
|
||||
<!-- Matching asset file: https://to.healthchat.ch/.well-known/assetlinks.json -->
|
||||
<data android:host="to.healthchat.ch" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
matrix: links
|
||||
@@ -128,7 +114,7 @@
|
||||
<data android:scheme="matrix" />
|
||||
</intent-filter>
|
||||
<!--
|
||||
links from matrix.to website
|
||||
links from the HealthChat matrix.to website, example: healthchat://room/%23room%3Ahealthchat.ch
|
||||
-->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -136,7 +122,7 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="element" />
|
||||
<data android:scheme="healthchat" />
|
||||
<data android:host="user" />
|
||||
<data android:host="room" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -22,19 +22,13 @@ android {
|
||||
defaultConfig {
|
||||
buildConfigFieldStr(
|
||||
name = "URL_POLICY",
|
||||
value = if (isEnterpriseBuild) {
|
||||
BuildTimeConfig.URL_POLICY ?: ""
|
||||
} else {
|
||||
"https://element.io/cookie-policy"
|
||||
},
|
||||
// HealthChat fork: a configured value wins for every build type, not only enterprise ones.
|
||||
value = BuildTimeConfig.URL_POLICY ?: if (isEnterpriseBuild) "" else "https://element.io/cookie-policy",
|
||||
)
|
||||
buildConfigFieldStr(
|
||||
name = "BUG_REPORT_URL",
|
||||
value = if (isEnterpriseBuild) {
|
||||
BuildTimeConfig.BUG_REPORT_URL ?: ""
|
||||
} else {
|
||||
"https://rageshakes.element.io/api/submit"
|
||||
},
|
||||
// HealthChat fork: a configured value wins for every build type, not only enterprise ones.
|
||||
value = BuildTimeConfig.BUG_REPORT_URL ?: if (isEnterpriseBuild) "" else "https://rageshakes.element.io/api/submit",
|
||||
)
|
||||
buildConfigFieldStr(
|
||||
name = "BUG_REPORT_APP_NAME",
|
||||
|
||||
@@ -24,12 +24,12 @@ object ApplicationConfig {
|
||||
* Cannot be empty.
|
||||
* For Element, the value is "Element".
|
||||
*/
|
||||
const val PRODUCTION_APPLICATION_NAME: String = "Element"
|
||||
const val PRODUCTION_APPLICATION_NAME: String = "HealthChat"
|
||||
|
||||
/**
|
||||
* Used in the strings to reference the Element Desktop client, for instance Element Web.
|
||||
* Cannot be empty.
|
||||
* For Element, the value is "Element". We use the same name for desktop and mobile for now.
|
||||
*/
|
||||
const val DESKTOP_APPLICATION_NAME: String = "Element"
|
||||
const val DESKTOP_APPLICATION_NAME: String = "HealthChat"
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
package io.element.android.appconfig
|
||||
|
||||
object LearnMoreConfig {
|
||||
const val ENCRYPTION_URL: String = "https://element.io/help#encryption"
|
||||
const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification"
|
||||
const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5"
|
||||
const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18"
|
||||
const val HISTORY_VISIBLE_URL: String = "https://element.io/en/help#e2ee-history-sharing"
|
||||
// HealthChat fork: every "learn more" link points at the HealthChat site instead of element.io/help.
|
||||
const val ENCRYPTION_URL: String = "https://www.healthchat.ch"
|
||||
const val DEVICE_VERIFICATION_URL: String = "https://www.healthchat.ch"
|
||||
const val SECURE_BACKUP_URL: String = "https://www.healthchat.ch"
|
||||
const val IDENTITY_CHANGE_URL: String = "https://www.healthchat.ch"
|
||||
const val HISTORY_VISIBLE_URL: String = "https://www.healthchat.ch"
|
||||
}
|
||||
|
||||
@@ -10,5 +10,19 @@ package io.element.android.appconfig
|
||||
|
||||
object MatrixConfiguration {
|
||||
const val MATRIX_TO_PERMALINK_BASE_URL: String = "https://matrix.to/#/"
|
||||
|
||||
/**
|
||||
* HealthChat's own matrix.to deployment (https://github.com/Novaloop-AG/healthchat-matrix.to).
|
||||
* It uses the exact same URL format as matrix.to, so links can be translated by swapping the
|
||||
* base URL. Used both to recognise incoming links and to build links shared outside of Matrix.
|
||||
*/
|
||||
const val HEALTHCHAT_TO_PERMALINK_BASE_URL: String = "https://to.healthchat.ch/#/"
|
||||
|
||||
/**
|
||||
* Base URL of the HealthChat web client, which is what the healthchat:// custom scheme
|
||||
* handed out by [HEALTHCHAT_TO_PERMALINK_BASE_URL] points to.
|
||||
*/
|
||||
const val HEALTHCHAT_WEB_BASE_URL: String = "https://app.healthchat.ch/#/"
|
||||
|
||||
val clientPermalinkBaseUrl: String? = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.appicon.healthchat"
|
||||
|
||||
buildTypes {
|
||||
register("nightly")
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2026 Novaloop AG
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#12325C"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="m0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2026 Novaloop AG
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright (c) 2026 Novaloop AG
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -362,6 +362,11 @@ class LoggedInFlowNode(
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
callback.navigateToSession(sessionId)
|
||||
}
|
||||
|
||||
// HealthChat fork: open a user profile from the member directory tab.
|
||||
override fun navigateToUserProfile(userId: UserId) {
|
||||
backstack.push(NavTarget.UserProfile(userId))
|
||||
}
|
||||
}
|
||||
homeEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Element X Android deeplink
|
||||
# HealthChat Android deeplink
|
||||
|
||||
<!--- TOC -->
|
||||
|
||||
@@ -12,31 +12,45 @@
|
||||
|
||||
## Introduction
|
||||
|
||||
Element X Android supports deep linking to specific screens in the application. This document explains how to use deep links in Element X Android.
|
||||
HealthChat Android supports deep linking to specific screens in the application. This document explains how to use deep links in HealthChat Android.
|
||||
|
||||
### Asset Links
|
||||
|
||||
The asset links file is available at https://element.io/.well-known/assetlinks.json
|
||||
Two hosts are claimed as verified Android App Links, and each one must serve a matching asset links
|
||||
file listing `ch.healthchat.element` and the SHA-256 of the Play app signing certificate:
|
||||
|
||||
| Host | Asset links file | Served from |
|
||||
|---|---|---|
|
||||
| `app.healthchat.ch` | https://app.healthchat.ch/.well-known/assetlinks.json | the `element-web` fork (`apps/web/res/.well-known/assetlinks.json`) |
|
||||
| `to.healthchat.ch` | https://to.healthchat.ch/.well-known/assetlinks.json | the `healthchat-matrix.to` fork (generated by `scripts/build.js`) |
|
||||
|
||||
### Supported links
|
||||
|
||||
Element Call link:
|
||||
> https://call.element.io/Example
|
||||
|
||||
Link to a user:
|
||||
> https://app.element.io/#/user/@alice:matrix.org
|
||||
> https://to.healthchat.ch/#/@alice:healthchat.ch
|
||||
> https://app.healthchat.ch/#/user/@alice:healthchat.ch
|
||||
|
||||
Link to a room by id or alias:
|
||||
> https://app.element.io/#/room/!roomid:matrix.org
|
||||
> https://app.element.io/#/room/#element-x-android:matrix.org
|
||||
> https://to.healthchat.ch/#/!roomid:healthchat.ch
|
||||
> https://app.healthchat.ch/#/room/#healthchat:healthchat.ch
|
||||
|
||||
Link to a room with a specific event:
|
||||
> https://app.element.io/#/room/!roomid:matrix.org/$eventid
|
||||
> https://app.healthchat.ch/#/room/!roomid:healthchat.ch/$eventid
|
||||
|
||||
Note that it will also work with other domain such as:
|
||||
> https://mobile.element.io
|
||||
> https://develop.element.io
|
||||
> https://staging.element.io
|
||||
The HealthChat matrix.to deployment hands out a custom scheme to Android, which is registered too:
|
||||
> healthchat://room/%23healthchat%3Ahealthchat.ch
|
||||
> healthchat://user/%40alice%3Ahealthchat.ch
|
||||
|
||||
Standard Matrix URIs also work:
|
||||
> matrix:r/healthchat:healthchat.ch
|
||||
|
||||
Only the two hosts above are declared in the manifest, so only those open the app from a browser.
|
||||
`DefaultMatrixToConverter` is host-agnostic though: any URL using the `/#/room/`, `/#/user/` or
|
||||
`/#/group/` path, as well as plain matrix.to links, are still resolved once they reach the app
|
||||
(for instance when tapped inside a message).
|
||||
|
||||
Finally, notifications use an internal deep link that is not browsable:
|
||||
> healthchat://open/{sessionId}/{roomId}/{eventId}
|
||||
|
||||
## Developer tools
|
||||
|
||||
@@ -44,28 +58,34 @@ Using an Android 12 or higher emulator
|
||||
|
||||
Ensure links verification is enabled
|
||||
```bash
|
||||
adb shell am compat enable 175408749 io.element.android.x.debug
|
||||
adb shell am compat enable 175408749 ch.healthchat.element.debug
|
||||
```
|
||||
|
||||
Reset link verifications for the given package id
|
||||
```bash
|
||||
adb shell pm set-app-links --package io.element.android.x.debug 0 all
|
||||
adb shell pm set-app-links --package ch.healthchat.element.debug 0 all
|
||||
```
|
||||
|
||||
Force the package id links to be verified
|
||||
```bash
|
||||
adb shell pm verify-app-links --re-verify io.element.android.x.debug
|
||||
adb shell pm verify-app-links --re-verify ch.healthchat.element.debug
|
||||
```
|
||||
|
||||
Print the link verification of the package id
|
||||
```bash
|
||||
adb shell pm get-app-links io.element.android.x.debug
|
||||
adb shell pm get-app-links ch.healthchat.element.debug
|
||||
```
|
||||
|
||||
```
|
||||
io.element.android.x.debug:
|
||||
ch.healthchat.element.debug:
|
||||
ID: e2ece472-c266-4bf0-829c-be79959a6270
|
||||
Signatures: [B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E]
|
||||
Domain verification state:
|
||||
*.element.io: 1024
|
||||
app.healthchat.ch: verified
|
||||
to.healthchat.ch: verified
|
||||
```
|
||||
|
||||
Verification only succeeds if the signature above is listed in the asset links files. Release builds
|
||||
are signed by Google Play, so use the app signing certificate SHA-256 from the Play Console. For a
|
||||
debug build, add the fingerprint of `app/signature/debug.keystore` (alias `androiddebugkey`) to both
|
||||
files, or test with `healthchat://` links instead, which need no verification.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
HealthChat ist die durchgehend verschlüsselte (Ende-zu-Ende-verschlüsselte, E2EE) Kommunikationslösung für HealthProfessionals – entwickelt in der Schweiz, für das Schweizer Gesundheitssystem.
|
||||
|
||||
Ob Ärztinnen und Ärzte, Pflegefachpersonen, Medizinische Praxisassistentinnen, Spitex-Fachkräfte oder Therapeutinnen – HealthChat ermöglicht eine sichere, einfache und effiziente Kommunikation innerhalb und zwischen interprofessionellen Teams.
|
||||
@@ -0,0 +1 @@
|
||||
Sichere Kommunikation für Gesundheitsfachpersonen
|
||||
@@ -0,0 +1 @@
|
||||
HealthChat
|
||||
@@ -1,29 +1,3 @@
|
||||
Freedom to communicate on your own terms
|
||||
HealthChat ist die durchgehend verschlüsselte (Ende-zu-Ende-verschlüsselte, E2EE) Kommunikationslösung für HealthProfessionals – entwickelt in der Schweiz, für das Schweizer Gesundheitssystem.
|
||||
|
||||
For individuals and communities - private communication between family, friends, hobby groups, clubs, etc.
|
||||
|
||||
Element X gives you fast, secure and private instant messaging and video calls built on Matrix, the open standard for real-time communication. This is a free and open-source app maintained at https://github.com/element-hq/element-x-android.
|
||||
|
||||
Stay in touch with friends, family and communities with:
|
||||
• Real time messaging & video calls
|
||||
• Public rooms for open group communication
|
||||
• Private rooms for closed group communication
|
||||
• Rich messaging features: emoji reactions, replies, polls, pinned messages and more.
|
||||
• Video calling while browsing messages.
|
||||
• Interoperability with other Matrix-based apps such as FluffyChat, Cinny and many more.
|
||||
|
||||
<b>Privacy-first<b>
|
||||
Unlike some other messengers from Big Tech companies, we don’t mine your data or monitor your communications.
|
||||
|
||||
<b>Own your conversations</b>
|
||||
Choose where to host your data - from any public server (the largest free server is matrix.org, but there are plenty of others to choose from) to creating your own personal server and hosting it on your own domain. This ability to choose a server is a large part of what differentiates us from other real time communication apps. However you host, you have ownership; it’s your data. You’re not the product. You’re in control.
|
||||
|
||||
<b>Communicate in real time, all the time</b>
|
||||
Use Element everywhere. Stay in touch wherever you are with fully synchronised message history across all your devices, including on the web at https://app.element.io
|
||||
|
||||
<b>Element X is our next-generation app</b>
|
||||
If you’re using the previous-generation Element Classic app, it’s time to try Element X! It’s faster, easier to use, and more powerful than the classic app. It’s better in every way and we’re adding new features all the time.
|
||||
|
||||
The application requires the android.permission.REQUEST_INSTALL_PACKAGES permission to enable the installation of applications received as attachments, ensuring seamless and convenient access to new software within the app.
|
||||
|
||||
The application requires the USE_FULL_SCREEN_INTENT permission to ensure our users can effectively receive call notifications even when their devices are locked.
|
||||
Ob Ärztinnen und Ärzte, Pflegefachpersonen, Medizinische Praxisassistentinnen, Spitex-Fachkräfte oder Therapeutinnen – HealthChat ermöglicht eine sichere, einfache und effiziente Kommunikation innerhalb und zwischen interprofessionellen Teams.
|
||||
|
||||
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 22 KiB |
@@ -1 +1 @@
|
||||
Sovereign. Seamless. On Matrix
|
||||
Sichere Kommunikation für Gesundheitsfachpersonen
|
||||
|
||||
@@ -1 +1 @@
|
||||
Element X - Secure Chat & Call
|
||||
HealthChat
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.compound.tokens.generated.SemanticColors
|
||||
import io.element.android.features.enterprise.api.BugReportUrl
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
@@ -37,9 +38,9 @@ class DefaultEnterpriseService(
|
||||
) : EnterpriseService {
|
||||
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
|
||||
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
|
||||
override fun accountProviderAllowList(): List<AccountProvider> = emptyList()
|
||||
override fun canConnectToAnyAccountProvider(): Boolean = true
|
||||
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = true
|
||||
override fun accountProviderAllowList(): List<AccountProvider> = listOf(healthChatAccountProvider)
|
||||
override fun canConnectToAnyAccountProvider(): Boolean = false
|
||||
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = healthChatAccountProvider.matches(accountProvider)
|
||||
override suspend fun isElementProEnforced(serverName: String): Boolean = withContext(dispatchers.io) {
|
||||
val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return@withContext false }
|
||||
temporaryMatrixClient.use { client ->
|
||||
@@ -64,7 +65,7 @@ class DefaultEnterpriseService(
|
||||
}
|
||||
|
||||
override fun semanticColorsFlow(sessionId: SessionId?): Flow<SemanticColorsLightDark> {
|
||||
return flowOf(SemanticColorsLightDark.default)
|
||||
return flowOf(healthChatSemanticColors)
|
||||
}
|
||||
|
||||
override fun firebasePushGateway(): String? = null
|
||||
@@ -77,6 +78,32 @@ class DefaultEnterpriseService(
|
||||
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
|
||||
}
|
||||
|
||||
/** HealthChat brand blue, the launcher background colour. */
|
||||
private val healthChatBrandBlue = Color(0xFF12325C)
|
||||
|
||||
/**
|
||||
* The subtle gradient (home screen bloom, highlighted timeline item) fades the brand blue out at the
|
||||
* opacities Compound uses for its green stops; stop 6 stays transparent. Every other token, including the
|
||||
* accent colours, is Compound's default: only the bloom changes.
|
||||
*/
|
||||
private fun SemanticColors.withHealthChatBloom() = copy(
|
||||
gradientSubtleStop1 = healthChatBrandBlue.copy(alpha = 0.41f),
|
||||
gradientSubtleStop2 = healthChatBrandBlue.copy(alpha = 0.23f),
|
||||
gradientSubtleStop3 = healthChatBrandBlue.copy(alpha = 0.11f),
|
||||
gradientSubtleStop4 = healthChatBrandBlue.copy(alpha = 0.06f),
|
||||
gradientSubtleStop5 = healthChatBrandBlue.copy(alpha = 0.03f),
|
||||
)
|
||||
|
||||
internal val healthChatSemanticColors = SemanticColorsLightDark(
|
||||
light = SemanticColorsLightDark.default.light.withHealthChatBloom(),
|
||||
dark = SemanticColorsLightDark.default.dark.withHealthChatBloom(),
|
||||
)
|
||||
|
||||
/**
|
||||
* HealthChat is locked to its own homeserver: this is the only account provider users can sign in to.
|
||||
*/
|
||||
private val healthChatAccountProvider = AccountProvider.Generic(serverName = "matrix.healthchat.ch")
|
||||
|
||||
/**
|
||||
* A minimal version of the enterprise config that only contains the fields we need to check if Element Pro is enforced.
|
||||
*/
|
||||
|
||||
@@ -8,12 +8,14 @@
|
||||
|
||||
package io.element.android.features.enterprise.impl
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.compound.colors.SemanticColorsLightDark
|
||||
import io.element.android.features.enterprise.api.BugReportUrl
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
|
||||
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClient
|
||||
@@ -22,6 +24,7 @@ import io.element.android.libraries.matrix.test.accountprovider.anAccountProvide
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -29,21 +32,24 @@ import org.junit.Test
|
||||
|
||||
class DefaultEnterpriseServiceTest {
|
||||
@Test
|
||||
fun accountProviderAllowList() = runTest {
|
||||
fun `accountProviderAllowList contains the HealthChat homeserver only`() = runTest {
|
||||
val defaultEnterpriseService = createDefaultEnterpriseService()
|
||||
assertThat(defaultEnterpriseService.accountProviderAllowList()).isEmpty()
|
||||
assertThat(defaultEnterpriseService.accountProviderAllowList())
|
||||
.containsExactly(AccountProvider.Generic(serverName = "matrix.healthchat.ch"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canConnectToAnyAccountProvider is always true`() = runTest {
|
||||
fun `canConnectToAnyAccountProvider is always false`() = runTest {
|
||||
val defaultEnterpriseService = createDefaultEnterpriseService()
|
||||
assertThat(defaultEnterpriseService.canConnectToAnyAccountProvider()).isTrue()
|
||||
assertThat(defaultEnterpriseService.canConnectToAnyAccountProvider()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isAllowedToConnectToAccountProvider is true for all account providers`() = runTest {
|
||||
fun `isAllowedToConnectToAccountProvider is true for the HealthChat homeserver only`() = runTest {
|
||||
val defaultEnterpriseService = createDefaultEnterpriseService()
|
||||
assertThat(defaultEnterpriseService.isAllowedToConnectToAccountProvider(anAccountProviderGeneric())).isTrue()
|
||||
assertThat(defaultEnterpriseService.isAllowedToConnectToAccountProvider(AccountProvider.Generic("matrix.healthchat.ch"))).isTrue()
|
||||
assertThat(defaultEnterpriseService.isAllowedToConnectToAccountProvider(AccountProvider.Generic("https://matrix.healthchat.ch/"))).isTrue()
|
||||
assertThat(defaultEnterpriseService.isAllowedToConnectToAccountProvider(anAccountProviderGeneric())).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,11 +63,22 @@ class DefaultEnterpriseServiceTest {
|
||||
val defaultEnterpriseService = createDefaultEnterpriseService()
|
||||
defaultEnterpriseService.semanticColorsFlow(null).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(SemanticColorsLightDark.default)
|
||||
assertThat(initialState).isEqualTo(healthChatSemanticColors)
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `semanticColorsFlow only changes the subtle gradient to the HealthChat blue`() = runTest {
|
||||
val colors = createDefaultEnterpriseService().semanticColorsFlow(null).first()
|
||||
val default = SemanticColorsLightDark.default
|
||||
assertThat(colors.light.gradientSubtleStop1).isEqualTo(Color(0xFF12325C).copy(alpha = 0.41f))
|
||||
assertThat(colors.dark.gradientSubtleStop5).isEqualTo(Color(0xFF12325C).copy(alpha = 0.03f))
|
||||
assertThat(colors.light.gradientSubtleStop6).isEqualTo(default.light.gradientSubtleStop6)
|
||||
assertThat(colors.light.bgAccentRest).isEqualTo(default.light.bgAccentRest)
|
||||
assertThat(colors.dark.bgAccentRest).isEqualTo(default.dark.bgAccentRest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `brandColorsFlow always emits null`() = runTest {
|
||||
val defaultEnterpriseService = createDefaultEnterpriseService()
|
||||
@@ -77,7 +94,7 @@ class DefaultEnterpriseServiceTest {
|
||||
val defaultEnterpriseService = createDefaultEnterpriseService()
|
||||
defaultEnterpriseService.semanticColorsFlow(A_SESSION_ID).test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState).isEqualTo(SemanticColorsLightDark.default)
|
||||
assertThat(initialState).isEqualTo(healthChatSemanticColors)
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.healthchat.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.coroutines.core)
|
||||
api(projects.libraries.architecture)
|
||||
implementation(projects.libraries.designsystem)
|
||||
api(projects.libraries.matrix.api)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Renders the HealthChat account status banners ("account under review", "profile incomplete").
|
||||
*
|
||||
* Both states resolve server-side (approval, completed profile), so the banners are
|
||||
* deliberately not dismissible. When nothing needs attention, nothing is rendered.
|
||||
*/
|
||||
fun interface HealthChatAccountStatusRenderer {
|
||||
/**
|
||||
* @param onCompleteProfileClick called when the user taps the banner action to complete their profile.
|
||||
* @param includePendingApproval false when the host screen already shows its own
|
||||
* pending-approval banner (e.g. the member directory with the verbatim server message).
|
||||
* @param modifier the modifier to apply to the banner.
|
||||
*/
|
||||
@Composable
|
||||
fun Render(
|
||||
onCompleteProfileClick: () -> Unit,
|
||||
includePendingApproval: Boolean,
|
||||
modifier: Modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
/**
|
||||
* Typed error for all HealthChat API failures.
|
||||
*
|
||||
* Invariant: every failed [Result] returned by a HealthChat service carries a
|
||||
* [HealthChatApiException] (never a raw Retrofit/OkHttp exception).
|
||||
*
|
||||
* [serverMessage] values are German texts generated by the server and are rendered verbatim;
|
||||
* they can be null (e.g. a bare 403 Forbid has no body), in which case the UI falls back
|
||||
* to a localized string.
|
||||
*/
|
||||
sealed class HealthChatApiException(
|
||||
override val message: String,
|
||||
override val cause: Throwable?,
|
||||
) : Exception(message, cause) {
|
||||
/** HTTP 401: the Matrix access token was rejected. */
|
||||
data class Unauthorized(
|
||||
val serverMessage: String?,
|
||||
) : HealthChatApiException(serverMessage ?: "unauthorized", null)
|
||||
|
||||
/** HTTP 403: the calling account is not approved (yet). */
|
||||
data class NotApproved(
|
||||
val serverMessage: String?,
|
||||
) : HealthChatApiException(serverMessage ?: "not approved", null)
|
||||
|
||||
/** HTTP 404: the requested resource does not exist. */
|
||||
data class NotFound(
|
||||
val serverMessage: String?,
|
||||
) : HealthChatApiException(serverMessage ?: "not found", null)
|
||||
|
||||
/** Any other HTTP error with the regular `{message}` error body shape. */
|
||||
data class Http(
|
||||
val status: Int,
|
||||
val serverMessage: String?,
|
||||
) : HealthChatApiException(serverMessage ?: "HTTP $status", null)
|
||||
|
||||
/**
|
||||
* ASP.NET model validation error (`{title, status, errors: {Field: [msg]}}`).
|
||||
*
|
||||
* [fieldErrors] keys are lower-cased field names (e.g. "email", "personalnote").
|
||||
*/
|
||||
data class Validation(
|
||||
val status: Int,
|
||||
val title: String?,
|
||||
val fieldErrors: ImmutableMap<String, ImmutableList<String>>,
|
||||
) : HealthChatApiException(title ?: "validation failed", null)
|
||||
|
||||
/** The request never reached the server (I/O, timeout, no token). */
|
||||
data class Network(
|
||||
override val cause: Throwable,
|
||||
) : HealthChatApiException("network error", cause)
|
||||
|
||||
/** Anything else (unparseable response, mapping failure, programming error). */
|
||||
data class Unexpected(
|
||||
override val cause: Throwable?,
|
||||
) : HealthChatApiException("unexpected error", cause)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Profile completeness as reported by the HealthChat API.
|
||||
*
|
||||
* [missingFields] contains German labels generated by the server and is rendered verbatim
|
||||
* (with a best-effort localisation mapping on top).
|
||||
*/
|
||||
@Immutable
|
||||
data class HealthChatCompleteness(
|
||||
val isComplete: Boolean,
|
||||
val missingFields: ImmutableList<String>,
|
||||
val message: String?,
|
||||
val hasProfilePhoto: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Stateless access to the HealthChat member directory.
|
||||
*
|
||||
* All failures are [HealthChatApiException]s. There is no caching: every call hits the API.
|
||||
*/
|
||||
interface HealthChatDirectoryService {
|
||||
/**
|
||||
* Search the member directory. A blank [query] returns the full directory.
|
||||
* Search is server-side (name, practice, city, specialty, further info).
|
||||
*/
|
||||
suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory>
|
||||
|
||||
/**
|
||||
* Fetch a single member by Matrix user id.
|
||||
* Fails with [HealthChatApiException.NotApproved] when the calling account is not approved,
|
||||
* and [HealthChatApiException.NotFound] when the user is not a directory member.
|
||||
*/
|
||||
suspend fun getMember(userId: UserId): Result<HealthChatMember>
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Entry point into the fork-owned HealthChat flow (profile editor, member invitation).
|
||||
*/
|
||||
fun interface HealthChatEntryPoint : FeatureEntryPoint {
|
||||
data class Params(
|
||||
val initialTarget: InitialTarget,
|
||||
) : NodeInputs
|
||||
|
||||
sealed interface InitialTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object EditOwnProfile : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object Invite : InitialTarget
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
params: Params,
|
||||
callback: Callback,
|
||||
): Node
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
/**
|
||||
* Invite a new member to HealthChat by email.
|
||||
*
|
||||
* All failures are [HealthChatApiException]s; server-side field validation surfaces as
|
||||
* [HealthChatApiException.Validation] with lower-cased field keys ("email", "personalnote").
|
||||
*/
|
||||
interface HealthChatInviteService {
|
||||
/**
|
||||
* @param email the invitee's email address.
|
||||
* @param personalNote a personal message, at least 10 characters (server-enforced).
|
||||
* @return the German confirmation message from the server.
|
||||
*/
|
||||
suspend fun invite(email: String, personalNote: String): Result<String>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* A member of the HealthChat directory, as returned by the HealthChat API.
|
||||
*
|
||||
* All profile fields are nullable: the server sanitizer blanks fields in list responses,
|
||||
* and members may simply not have filled them in. Blank values are normalized to null.
|
||||
*/
|
||||
@Immutable
|
||||
data class HealthChatMember(
|
||||
val userId: UserId,
|
||||
val displayName: String?,
|
||||
/** Avatar as a mxc:// URI, resolved by the app's regular media loading. */
|
||||
val avatarUrl: String?,
|
||||
val title: String?,
|
||||
val firstName: String?,
|
||||
val lastName: String?,
|
||||
val specialty: String?,
|
||||
val practiceName: String?,
|
||||
val businessCity: String?,
|
||||
val furtherInfo: String?,
|
||||
val businessEmail: String?,
|
||||
val businessTel: String?,
|
||||
val street: String?,
|
||||
val houseNumber: String?,
|
||||
val postalCode: String?,
|
||||
val website: String?,
|
||||
val isApproved: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* The result of a HealthChat member directory query.
|
||||
*
|
||||
* When the calling account is not approved yet, [isApproved] is false, [members] only contains
|
||||
* the server's default members and [pendingMessage] carries a German explanation from the server.
|
||||
*/
|
||||
@Immutable
|
||||
data class HealthChatMemberDirectory(
|
||||
val members: ImmutableList<HealthChatMember>,
|
||||
val totalCount: Int,
|
||||
val searchQuery: String,
|
||||
val isApproved: Boolean,
|
||||
val pendingMessage: String?,
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
/**
|
||||
* Display name fallback chain, mirroring the web client:
|
||||
* displayName → "title firstName lastName" → businessEmail → [fallback].
|
||||
*/
|
||||
fun HealthChatMember.bestDisplayName(fallback: String): String {
|
||||
displayName?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val composedName = listOfNotNull(title, firstName, lastName)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ")
|
||||
if (composedName.isNotBlank()) return composedName
|
||||
businessEmail?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* The Matrix representation of a directory member, e.g. to start a DM through the app's
|
||||
* regular flows.
|
||||
*/
|
||||
fun HealthChatMember.toMatrixUser() = MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Access to the own HealthChat profile.
|
||||
*
|
||||
* The [StateFlow]s exist so that a successful write on one screen propagates to every other
|
||||
* screen observing the profile (same role as `MatrixClient.userProfile`). They are in-memory
|
||||
* only, never persisted, and never used as a fallback for failed requests.
|
||||
*
|
||||
* All failures are [HealthChatApiException]s.
|
||||
*/
|
||||
interface HealthChatProfileRepository {
|
||||
/** The own profile. Updated by [refreshOwnProfile] and [updateOwnProfile]. */
|
||||
val ownProfile: StateFlow<AsyncData<HealthChatMember>>
|
||||
|
||||
/** The own profile completeness. Updated by [refreshCompleteness]. */
|
||||
val completeness: StateFlow<AsyncData<HealthChatCompleteness>>
|
||||
|
||||
suspend fun refreshOwnProfile(): Result<HealthChatMember>
|
||||
|
||||
suspend fun refreshCompleteness(): Result<HealthChatCompleteness>
|
||||
|
||||
/**
|
||||
* Apply a partial profile update ([HealthChatProfileUpdate] semantics), then re-fetch the
|
||||
* profile so [ownProfile] reflects the server-derived state (e.g. the display name).
|
||||
*
|
||||
* @return the German confirmation message from the server.
|
||||
*/
|
||||
suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String>
|
||||
|
||||
/**
|
||||
* Upload a new avatar (max 2 MiB, image mime types only) and return its `mxc://` URL.
|
||||
* On success [ownProfile] is refreshed.
|
||||
*/
|
||||
suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String>
|
||||
|
||||
/**
|
||||
* Remove the current avatar. On success [ownProfile] is refreshed.
|
||||
*
|
||||
* @return the German confirmation message from the server.
|
||||
*/
|
||||
suspend fun deleteAvatar(): Result<String>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Renders the HealthChat profile fields (specialty, practice, contact details, …) for a user.
|
||||
*
|
||||
* Injected into the upstream profile screens as a slot so the fork's only upstream diff is
|
||||
* one defaulted parameter plus the injection (same pattern as `InvitePeopleRenderer`).
|
||||
*/
|
||||
fun interface HealthChatProfileSectionRenderer {
|
||||
@Composable
|
||||
fun Render(
|
||||
userId: UserId,
|
||||
isCurrentUser: Boolean,
|
||||
modifier: Modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
/**
|
||||
* A partial update of the own HealthChat profile.
|
||||
*
|
||||
* Semantics per field (mirrors the HealthChat API contract):
|
||||
* - `null`: the field is left untouched on the server.
|
||||
* - `""`: the field is cleared on the server.
|
||||
* - any other value: the field is set to that value.
|
||||
*
|
||||
* Writing [firstName]/[lastName] makes the server re-derive the Matrix display name and
|
||||
* sync it to Zitadel; the client must never call `setDisplayName` itself.
|
||||
*/
|
||||
data class HealthChatProfileUpdate(
|
||||
val title: String? = null,
|
||||
val firstName: String? = null,
|
||||
val lastName: String? = null,
|
||||
val specialty: String? = null,
|
||||
val practiceName: String? = null,
|
||||
val businessCity: String? = null,
|
||||
val furtherInfo: String? = null,
|
||||
val businessEmail: String? = null,
|
||||
val businessTel: String? = null,
|
||||
val street: String? = null,
|
||||
val houseNumber: String? = null,
|
||||
val postalCode: String? = null,
|
||||
val website: String? = null,
|
||||
) {
|
||||
fun isEmpty(): Boolean = this == HealthChatProfileUpdate()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api
|
||||
|
||||
/**
|
||||
* Fork-owned HealthChat strings (de/en/fr/it), maintained in the `strings_healthchat.xml`
|
||||
* files under `features/healthchat/api/src/main/res`.
|
||||
* They are intentionally NOT part of the Localazy workflow.
|
||||
*/
|
||||
typealias HealthChatStrings = R.string
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api.directory
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
sealed interface MemberDirectoryEvent {
|
||||
/**
|
||||
* Fired by the view on first composition. The tab presenter runs from app start (it is
|
||||
* composed by the Home presenter), so the first fetch is deferred until the tab is shown.
|
||||
*/
|
||||
data object LoadIfNeeded : MemberDirectoryEvent
|
||||
|
||||
/** Header refresh and "try again" — one load path. */
|
||||
data object Refresh : MemberDirectoryEvent
|
||||
|
||||
data class StartDm(val matrixUser: MatrixUser) : MemberDirectoryEvent
|
||||
|
||||
data object ClearStartDmState : MemberDirectoryEvent
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api.directory
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Renders the member directory tab content. Injected into the Home screen as a slot so the
|
||||
* fork's upstream diff stays minimal.
|
||||
*
|
||||
* Nullable callbacks hide their affordance entirely (e.g. profile editing and inviting only
|
||||
* become available once the corresponding screens exist).
|
||||
*/
|
||||
fun interface MemberDirectoryRenderer {
|
||||
@Composable
|
||||
fun Render(
|
||||
state: MemberDirectoryState,
|
||||
contentPadding: PaddingValues,
|
||||
lazyListState: LazyListState,
|
||||
onOpenDm: (RoomId) -> Unit,
|
||||
onMemberClick: ((UserId) -> Unit)?,
|
||||
onMyProfileClick: (() -> Unit)?,
|
||||
onEditProfileClick: (() -> Unit)?,
|
||||
onInviteClick: (() -> Unit)?,
|
||||
modifier: Modifier,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api.directory
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
|
||||
data class MemberDirectoryState(
|
||||
val searchQuery: TextFieldState,
|
||||
val directory: AsyncData<HealthChatMemberDirectory>,
|
||||
val startDmActionState: AsyncAction<RoomId>,
|
||||
val eventSink: (MemberDirectoryEvent) -> Unit,
|
||||
) {
|
||||
/** Unknown-while-loading counts as approved so no banner flashes during the first load. */
|
||||
val isApproved: Boolean
|
||||
get() = directory.dataOrNull()?.isApproved != false
|
||||
|
||||
/** The verbatim German server explanation, only when the account is not approved. */
|
||||
val pendingMessage: String?
|
||||
get() = directory.dataOrNull()?.takeIf { !it.isApproved }?.pendingMessage
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.api.directory
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* Sample state builders, shared by previews inside the healthchat feature and by the Home
|
||||
* screen's previews (same pattern as `aDirectLogoutState` in `features/logout/api`).
|
||||
*/
|
||||
fun aMemberDirectoryState(
|
||||
searchQuery: TextFieldState = TextFieldState(),
|
||||
directory: AsyncData<HealthChatMemberDirectory> = AsyncData.Success(aMemberDirectory()),
|
||||
startDmActionState: AsyncAction<RoomId> = AsyncAction.Uninitialized,
|
||||
eventSink: (MemberDirectoryEvent) -> Unit = {},
|
||||
) = MemberDirectoryState(
|
||||
searchQuery = searchQuery,
|
||||
directory = directory,
|
||||
startDmActionState = startDmActionState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aMemberDirectory(
|
||||
members: List<HealthChatMember> = aMemberList(),
|
||||
totalCount: Int = members.size,
|
||||
searchQuery: String = "",
|
||||
isApproved: Boolean = true,
|
||||
pendingMessage: String? = null,
|
||||
) = HealthChatMemberDirectory(
|
||||
members = members.toImmutableList(),
|
||||
totalCount = totalCount,
|
||||
searchQuery = searchQuery,
|
||||
isApproved = isApproved,
|
||||
pendingMessage = pendingMessage,
|
||||
)
|
||||
|
||||
fun aMemberList(): List<HealthChatMember> = listOf(
|
||||
aDirectoryMember(),
|
||||
aDirectoryMember(
|
||||
userId = UserId("@luca.bianchi:healthchat.ch"),
|
||||
displayName = "Dr. med. Luca Bianchi",
|
||||
specialty = "Kardiologie",
|
||||
practiceName = "Herzzentrum Bellevue",
|
||||
businessCity = "Bern",
|
||||
businessEmail = null,
|
||||
businessTel = null,
|
||||
),
|
||||
aDirectoryMember(
|
||||
userId = UserId("@nora.keller:healthchat.ch"),
|
||||
displayName = null,
|
||||
specialty = null,
|
||||
practiceName = null,
|
||||
businessCity = null,
|
||||
businessEmail = "nora.keller@example.ch",
|
||||
businessTel = null,
|
||||
),
|
||||
)
|
||||
|
||||
fun aDirectoryMember(
|
||||
userId: UserId = UserId("@erika.muster:healthchat.ch"),
|
||||
displayName: String? = "Dr. med. Erika Muster",
|
||||
specialty: String? = "Allgemeinmedizin",
|
||||
practiceName: String? = "Praxis Muster",
|
||||
businessCity: String? = "Zürich",
|
||||
businessEmail: String? = "praxis@muster.ch",
|
||||
businessTel: String? = "+41 44 123 45 67",
|
||||
isApproved: Boolean = true,
|
||||
) = HealthChatMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = null,
|
||||
title = null,
|
||||
firstName = null,
|
||||
lastName = null,
|
||||
specialty = specialty,
|
||||
practiceName = practiceName,
|
||||
businessCity = businessCity,
|
||||
furtherInfo = null,
|
||||
businessEmail = businessEmail,
|
||||
businessTel = businessTel,
|
||||
street = null,
|
||||
houseNumber = null,
|
||||
postalCode = null,
|
||||
website = null,
|
||||
isApproved = isApproved,
|
||||
)
|
||||
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Novaloop AG
|
||||
~ Copyright (c) 2026 Element Creations Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
~
|
||||
~ Fork-owned HealthChat strings (German). NOT managed by Localazy.
|
||||
-->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
|
||||
<string name="healthchat_error_network">Ein Netzwerkfehler ist aufgetreten. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.</string>
|
||||
<string name="healthchat_error_unknown">Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.</string>
|
||||
<string name="healthchat_field_specialty">Fachgebiet</string>
|
||||
<string name="healthchat_field_practice_name">Praxis / Unternehmen</string>
|
||||
<string name="healthchat_field_address">Adresse</string>
|
||||
<string name="healthchat_field_business_email">E-Mail (geschäftlich)</string>
|
||||
<string name="healthchat_field_business_tel">Telefon (geschäftlich)</string>
|
||||
<string name="healthchat_field_website">Webseite</string>
|
||||
<string name="healthchat_field_further_info">Weitere Infos / Qualifikationen</string>
|
||||
<string name="healthchat_profile_error_loading">Die HealthChat-Profildaten konnten nicht geladen werden.</string>
|
||||
<string name="healthchat_profile_not_approved_hint">Profildetails sind sichtbar, sobald Ihr Konto freigegeben ist.</string>
|
||||
<string name="healthchat_screen_home_tab_members">Mitglieder</string>
|
||||
<plurals name="healthchat_screen_member_directory_member_count">
|
||||
<item quantity="one">%1$d Mitglied</item>
|
||||
<item quantity="other">%1$d Mitglieder</item>
|
||||
</plurals>
|
||||
<string name="healthchat_screen_member_directory_my_profile">Mein Profil</string>
|
||||
<string name="healthchat_screen_member_directory_search_placeholder">Suche nach Name, Praxis, Ort oder Fachgebiet</string>
|
||||
<string name="healthchat_screen_member_directory_start_chat">Chat starten</string>
|
||||
<string name="healthchat_screen_member_directory_error_starting_chat">Fehler beim Erstellen des Chats</string>
|
||||
<string name="healthchat_screen_member_directory_empty">Es sind noch keine Mitglieder vorhanden.</string>
|
||||
<string name="healthchat_screen_member_directory_empty_search">Keine Mitglieder entsprechen Ihrer Suche.</string>
|
||||
<string name="healthchat_screen_member_directory_unknown_name">Unbekannt</string>
|
||||
<string name="healthchat_screen_member_directory_pending_title">Konto wird geprüft</string>
|
||||
<string name="healthchat_screen_member_directory_pending_message">Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.</string>
|
||||
<string name="healthchat_a11y_refresh">Aktualisieren</string>
|
||||
<string name="healthchat_a11y_edit_profile">Profil bearbeiten</string>
|
||||
<string name="healthchat_a11y_invite_member">Neues Mitglied einladen</string>
|
||||
<string name="healthchat_field_name">Anzeigename</string>
|
||||
<string name="healthchat_field_title">Titel</string>
|
||||
<string name="healthchat_field_title_placeholder">z.B. Dr. med.</string>
|
||||
<string name="healthchat_field_first_name">Vorname</string>
|
||||
<string name="healthchat_field_last_name">Nachname</string>
|
||||
<string name="healthchat_field_specialty_placeholder">z.B. Allgemeinmedizin</string>
|
||||
<string name="healthchat_field_practice_name_placeholder">z.B. Praxis Muster</string>
|
||||
<string name="healthchat_field_city">Ort</string>
|
||||
<string name="healthchat_field_city_placeholder">z.B. Zürich</string>
|
||||
<string name="healthchat_field_business_email_placeholder">praxis@beispiel.ch</string>
|
||||
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
|
||||
<!-- Swiss German: "Strasse" (no ß) is correct for this product. -->
|
||||
<string name="healthchat_field_street" tools:ignore="Typos">Strasse</string>
|
||||
<string name="healthchat_field_house_number">Nr.</string>
|
||||
<string name="healthchat_field_postal_code">PLZ</string>
|
||||
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
|
||||
<string name="healthchat_screen_edit_profile_title">Profil bearbeiten</string>
|
||||
<string name="healthchat_screen_edit_profile_section_personal">Persönliche Angaben</string>
|
||||
<string name="healthchat_screen_edit_profile_section_professional">Berufliche Angaben</string>
|
||||
<string name="healthchat_screen_edit_profile_section_contact">Kontaktdaten</string>
|
||||
<string name="healthchat_screen_edit_profile_section_address">Praxisadresse</string>
|
||||
<string name="healthchat_screen_edit_profile_saving">Profil wird gespeichert…</string>
|
||||
<string name="healthchat_screen_edit_profile_error_title">Profil konnte nicht gespeichert werden</string>
|
||||
<string name="healthchat_screen_edit_profile_load_error">Das Profil konnte nicht geladen werden.</string>
|
||||
<string name="healthchat_screen_edit_profile_recommended">Für ein vollständiges Profil empfohlen</string>
|
||||
<string name="healthchat_error_field_required">Dieses Feld ist erforderlich</string>
|
||||
<string name="healthchat_error_invalid_email">Bitte eine gültige E-Mail-Adresse eingeben</string>
|
||||
<string name="healthchat_error_invalid_url">Bitte eine gültige URL eingeben (z.B. https://praxis-muster.ch)</string>
|
||||
<string name="healthchat_error_note_too_short">Die persönliche Nachricht muss mindestens 10 Zeichen lang sein</string>
|
||||
<string name="healthchat_error_avatar_too_large">Das Bild darf höchstens 2 MB gross sein.</string>
|
||||
<string name="healthchat_banner_incomplete_title">Profil unvollständig</string>
|
||||
<string name="healthchat_banner_incomplete_description">Bitte vervollständigen Sie Ihr Profil, damit andere Mitglieder Sie finden können. Fehlende Angaben: %1$s</string>
|
||||
<string name="healthchat_banner_incomplete_action">Profil vervollständigen</string>
|
||||
<string name="healthchat_screen_invite_title">Mitglied einladen</string>
|
||||
<string name="healthchat_screen_invite_description">Laden Sie neue Mitglieder zu HealthChat ein, indem Sie deren E-Mail-Adresse und eine persönliche Nachricht eingeben.</string>
|
||||
<string name="healthchat_screen_invite_email_label">E-Mail-Adresse</string>
|
||||
<string name="healthchat_screen_invite_email_placeholder">beispiel@domain.ch</string>
|
||||
<string name="healthchat_screen_invite_note_label">Persönliche Nachricht</string>
|
||||
<string name="healthchat_screen_invite_note_placeholder">Schreiben Sie eine persönliche Nachricht an das neue Mitglied…</string>
|
||||
<string name="healthchat_screen_invite_note_counter">%1$d/10 Zeichen</string>
|
||||
<string name="healthchat_screen_invite_send">Einladung senden</string>
|
||||
<string name="healthchat_screen_invite_sending">Einladung wird gesendet…</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Novaloop AG
|
||||
~ Copyright (c) 2026 Element Creations Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
~
|
||||
~ Fork-owned HealthChat strings (French). NOT managed by Localazy.
|
||||
-->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
|
||||
<string name="healthchat_error_network">Une erreur réseau est survenue. Veuillez vérifier votre connexion et réessayer.</string>
|
||||
<string name="healthchat_error_unknown">Une erreur est survenue. Veuillez réessayer.</string>
|
||||
<string name="healthchat_field_specialty">Spécialité</string>
|
||||
<string name="healthchat_field_practice_name">Cabinet / Entreprise</string>
|
||||
<string name="healthchat_field_address">Adresse</string>
|
||||
<string name="healthchat_field_business_email">E-mail (professionnel)</string>
|
||||
<string name="healthchat_field_business_tel">Téléphone (professionnel)</string>
|
||||
<string name="healthchat_field_website">Site web</string>
|
||||
<string name="healthchat_field_further_info">Informations complémentaires / qualifications</string>
|
||||
<string name="healthchat_profile_error_loading">Les détails du profil HealthChat n’ont pas pu être chargés.</string>
|
||||
<string name="healthchat_profile_not_approved_hint">Les détails du profil seront visibles dès que votre compte aura été approuvé.</string>
|
||||
<string name="healthchat_screen_home_tab_members">Membres</string>
|
||||
<plurals name="healthchat_screen_member_directory_member_count">
|
||||
<item quantity="one">%1$d membre</item>
|
||||
<item quantity="many">%1$d membres</item>
|
||||
<item quantity="other">%1$d membres</item>
|
||||
</plurals>
|
||||
<string name="healthchat_screen_member_directory_my_profile">Mon profil</string>
|
||||
<string name="healthchat_screen_member_directory_search_placeholder">Rechercher par nom, cabinet, ville ou spécialité</string>
|
||||
<string name="healthchat_screen_member_directory_start_chat">Démarrer une discussion</string>
|
||||
<string name="healthchat_screen_member_directory_error_starting_chat">Impossible de démarrer la discussion</string>
|
||||
<string name="healthchat_screen_member_directory_empty">Il n’y a pas encore de membres.</string>
|
||||
<string name="healthchat_screen_member_directory_empty_search">Aucun membre ne correspond à votre recherche.</string>
|
||||
<string name="healthchat_screen_member_directory_unknown_name">Inconnu</string>
|
||||
<string name="healthchat_screen_member_directory_pending_title">Compte en cours de vérification</string>
|
||||
<string name="healthchat_screen_member_directory_pending_message">Votre compte est en cours de vérification. Une fois approuvé, vous pourrez voir et rechercher tous les membres.</string>
|
||||
<string name="healthchat_a11y_refresh">Actualiser</string>
|
||||
<string name="healthchat_a11y_edit_profile">Modifier le profil</string>
|
||||
<string name="healthchat_a11y_invite_member">Inviter un nouveau membre</string>
|
||||
<string name="healthchat_field_name">Nom affiché</string>
|
||||
<string name="healthchat_field_title">Titre</string>
|
||||
<string name="healthchat_field_title_placeholder">p. ex. Dr méd.</string>
|
||||
<string name="healthchat_field_first_name">Prénom</string>
|
||||
<string name="healthchat_field_last_name">Nom</string>
|
||||
<string name="healthchat_field_specialty_placeholder">p. ex. médecine générale</string>
|
||||
<string name="healthchat_field_practice_name_placeholder">p. ex. Praxis Muster</string>
|
||||
<string name="healthchat_field_city">Ville</string>
|
||||
<string name="healthchat_field_city_placeholder">p. ex. Zurich</string>
|
||||
<string name="healthchat_field_business_email_placeholder">cabinet@exemple.ch</string>
|
||||
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
|
||||
<string name="healthchat_field_street">Rue</string>
|
||||
<string name="healthchat_field_house_number">N°</string>
|
||||
<string name="healthchat_field_postal_code">NPA</string>
|
||||
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
|
||||
<string name="healthchat_screen_edit_profile_title">Modifier le profil</string>
|
||||
<string name="healthchat_screen_edit_profile_section_personal">Informations personnelles</string>
|
||||
<string name="healthchat_screen_edit_profile_section_professional">Informations professionnelles</string>
|
||||
<string name="healthchat_screen_edit_profile_section_contact">Coordonnées</string>
|
||||
<string name="healthchat_screen_edit_profile_section_address">Adresse du cabinet</string>
|
||||
<string name="healthchat_screen_edit_profile_saving">Enregistrement du profil…</string>
|
||||
<string name="healthchat_screen_edit_profile_error_title">Impossible d’enregistrer le profil</string>
|
||||
<string name="healthchat_screen_edit_profile_load_error">Le profil n’a pas pu être chargé.</string>
|
||||
<string name="healthchat_screen_edit_profile_recommended">Recommandé pour un profil complet</string>
|
||||
<string name="healthchat_error_field_required">Ce champ est obligatoire</string>
|
||||
<string name="healthchat_error_invalid_email">Veuillez saisir une adresse e-mail valide</string>
|
||||
<string name="healthchat_error_invalid_url">Veuillez saisir une URL valide (p. ex. https://praxis-muster.ch)</string>
|
||||
<string name="healthchat_error_note_too_short">Le message personnel doit contenir au moins 10 caractères</string>
|
||||
<string name="healthchat_error_avatar_too_large">L’image ne doit pas dépasser 2 Mo.</string>
|
||||
<string name="healthchat_banner_incomplete_title">Profil incomplet</string>
|
||||
<string name="healthchat_banner_incomplete_description">Veuillez compléter votre profil pour que les autres membres puissent vous trouver. Informations manquantes : %1$s</string>
|
||||
<string name="healthchat_banner_incomplete_action">Compléter le profil</string>
|
||||
<string name="healthchat_screen_invite_title">Inviter un membre</string>
|
||||
<string name="healthchat_screen_invite_description">Invitez de nouveaux membres sur HealthChat en saisissant leur adresse e-mail et un message personnel.</string>
|
||||
<string name="healthchat_screen_invite_email_label">Adresse e-mail</string>
|
||||
<string name="healthchat_screen_invite_email_placeholder">exemple@domaine.ch</string>
|
||||
<string name="healthchat_screen_invite_note_label">Message personnel</string>
|
||||
<string name="healthchat_screen_invite_note_placeholder">Écrivez un message personnel au nouveau membre…</string>
|
||||
<string name="healthchat_screen_invite_note_counter">%1$d/10 caractères</string>
|
||||
<string name="healthchat_screen_invite_send">Envoyer l’invitation</string>
|
||||
<string name="healthchat_screen_invite_sending">Envoi de l’invitation…</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Novaloop AG
|
||||
~ Copyright (c) 2026 Element Creations Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
~
|
||||
~ Fork-owned HealthChat strings (Italian). NOT managed by Localazy.
|
||||
-->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
|
||||
<string name="healthchat_error_network">Si è verificato un errore di rete. Controlla la connessione e riprova.</string>
|
||||
<string name="healthchat_error_unknown">Qualcosa è andato storto. Riprova.</string>
|
||||
<string name="healthchat_field_specialty">Specializzazione</string>
|
||||
<string name="healthchat_field_practice_name">Studio / Azienda</string>
|
||||
<string name="healthchat_field_address">Indirizzo</string>
|
||||
<string name="healthchat_field_business_email">E-mail (professionale)</string>
|
||||
<string name="healthchat_field_business_tel">Telefono (professionale)</string>
|
||||
<string name="healthchat_field_website">Sito web</string>
|
||||
<string name="healthchat_field_further_info">Ulteriori informazioni / qualifiche</string>
|
||||
<string name="healthchat_profile_error_loading">Impossibile caricare i dettagli del profilo HealthChat.</string>
|
||||
<string name="healthchat_profile_not_approved_hint">I dettagli del profilo saranno visibili non appena il tuo account sarà stato approvato.</string>
|
||||
<string name="healthchat_screen_home_tab_members">Membri</string>
|
||||
<plurals name="healthchat_screen_member_directory_member_count">
|
||||
<item quantity="one">%1$d membro</item>
|
||||
<item quantity="many">%1$d membri</item>
|
||||
<item quantity="other">%1$d membri</item>
|
||||
</plurals>
|
||||
<string name="healthchat_screen_member_directory_my_profile">Il mio profilo</string>
|
||||
<string name="healthchat_screen_member_directory_search_placeholder">Cerca per nome, studio, città o specializzazione</string>
|
||||
<string name="healthchat_screen_member_directory_start_chat">Avvia chat</string>
|
||||
<string name="healthchat_screen_member_directory_error_starting_chat">Impossibile avviare la chat</string>
|
||||
<string name="healthchat_screen_member_directory_empty">Non ci sono ancora membri.</string>
|
||||
<string name="healthchat_screen_member_directory_empty_search">Nessun membro corrisponde alla tua ricerca.</string>
|
||||
<string name="healthchat_screen_member_directory_unknown_name">Sconosciuto</string>
|
||||
<string name="healthchat_screen_member_directory_pending_title">Account in fase di verifica</string>
|
||||
<string name="healthchat_screen_member_directory_pending_message">Il tuo account è attualmente in fase di verifica. Una volta approvato potrai vedere e cercare tutti i membri.</string>
|
||||
<string name="healthchat_a11y_refresh">Aggiorna</string>
|
||||
<string name="healthchat_a11y_edit_profile">Modifica profilo</string>
|
||||
<string name="healthchat_a11y_invite_member">Invita un nuovo membro</string>
|
||||
<string name="healthchat_field_name">Nome visualizzato</string>
|
||||
<string name="healthchat_field_title">Titolo</string>
|
||||
<string name="healthchat_field_title_placeholder">ad es. Dr. med.</string>
|
||||
<string name="healthchat_field_first_name">Nome</string>
|
||||
<string name="healthchat_field_last_name">Cognome</string>
|
||||
<string name="healthchat_field_specialty_placeholder">ad es. medicina generale</string>
|
||||
<string name="healthchat_field_practice_name_placeholder">ad es. Praxis Muster</string>
|
||||
<string name="healthchat_field_city">Città</string>
|
||||
<string name="healthchat_field_city_placeholder">ad es. Zurigo</string>
|
||||
<string name="healthchat_field_business_email_placeholder">studio@esempio.ch</string>
|
||||
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
|
||||
<string name="healthchat_field_street">Via</string>
|
||||
<string name="healthchat_field_house_number">N.</string>
|
||||
<string name="healthchat_field_postal_code">CAP</string>
|
||||
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
|
||||
<string name="healthchat_screen_edit_profile_title">Modifica profilo</string>
|
||||
<string name="healthchat_screen_edit_profile_section_personal">Dati personali</string>
|
||||
<string name="healthchat_screen_edit_profile_section_professional">Dati professionali</string>
|
||||
<string name="healthchat_screen_edit_profile_section_contact">Contatti</string>
|
||||
<string name="healthchat_screen_edit_profile_section_address">Indirizzo dello studio</string>
|
||||
<string name="healthchat_screen_edit_profile_saving">Salvataggio del profilo…</string>
|
||||
<string name="healthchat_screen_edit_profile_error_title">Impossibile salvare il profilo</string>
|
||||
<string name="healthchat_screen_edit_profile_load_error">Impossibile caricare il profilo.</string>
|
||||
<string name="healthchat_screen_edit_profile_recommended">Consigliato per un profilo completo</string>
|
||||
<string name="healthchat_error_field_required">Questo campo è obbligatorio</string>
|
||||
<string name="healthchat_error_invalid_email">Inserisci un indirizzo e-mail valido</string>
|
||||
<string name="healthchat_error_invalid_url">Inserisci un URL valido (ad es. https://praxis-muster.ch)</string>
|
||||
<string name="healthchat_error_note_too_short">Il messaggio personale deve contenere almeno 10 caratteri</string>
|
||||
<string name="healthchat_error_avatar_too_large">L’immagine non deve superare i 2 MB.</string>
|
||||
<string name="healthchat_banner_incomplete_title">Profilo incompleto</string>
|
||||
<string name="healthchat_banner_incomplete_description">Completa il tuo profilo affinché gli altri membri possano trovarti. Dati mancanti: %1$s</string>
|
||||
<string name="healthchat_banner_incomplete_action">Completa il profilo</string>
|
||||
<string name="healthchat_screen_invite_title">Invita un membro</string>
|
||||
<string name="healthchat_screen_invite_description">Invita nuovi membri su HealthChat inserendo il loro indirizzo e-mail e un messaggio personale.</string>
|
||||
<string name="healthchat_screen_invite_email_label">Indirizzo e-mail</string>
|
||||
<string name="healthchat_screen_invite_email_placeholder">esempio@dominio.ch</string>
|
||||
<string name="healthchat_screen_invite_note_label">Messaggio personale</string>
|
||||
<string name="healthchat_screen_invite_note_placeholder">Scrivi un messaggio personale al nuovo membro…</string>
|
||||
<string name="healthchat_screen_invite_note_counter">%1$d/10 caratteri</string>
|
||||
<string name="healthchat_screen_invite_send">Invia invito</string>
|
||||
<string name="healthchat_screen_invite_sending">Invio dell’invito…</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,82 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Novaloop AG
|
||||
~ Copyright (c) 2026 Element Creations Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
~
|
||||
~ Fork-owned HealthChat strings. NOT managed by Localazy — edit directly and keep
|
||||
~ values-de/, values-fr/ and values-it/ in sync.
|
||||
-->
|
||||
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="UnusedResources">
|
||||
<string name="healthchat_error_network">A network error occurred. Please check your connection and try again.</string>
|
||||
<string name="healthchat_error_unknown">Something went wrong. Please try again.</string>
|
||||
<string name="healthchat_field_specialty">Specialty</string>
|
||||
<string name="healthchat_field_practice_name">Practice / Company</string>
|
||||
<string name="healthchat_field_address">Address</string>
|
||||
<string name="healthchat_field_business_email">Email (business)</string>
|
||||
<string name="healthchat_field_business_tel">Phone (business)</string>
|
||||
<string name="healthchat_field_website">Website</string>
|
||||
<string name="healthchat_field_further_info">Further info / qualifications</string>
|
||||
<string name="healthchat_profile_error_loading">The HealthChat profile details couldn’t be loaded.</string>
|
||||
<string name="healthchat_profile_not_approved_hint">Profile details become visible once your account has been approved.</string>
|
||||
<string name="healthchat_screen_home_tab_members">Members</string>
|
||||
<plurals name="healthchat_screen_member_directory_member_count">
|
||||
<item quantity="one">%1$d member</item>
|
||||
<item quantity="other">%1$d members</item>
|
||||
</plurals>
|
||||
<string name="healthchat_screen_member_directory_my_profile">My profile</string>
|
||||
<string name="healthchat_screen_member_directory_search_placeholder">Search by name, practice, city or specialty</string>
|
||||
<string name="healthchat_screen_member_directory_start_chat">Start chat</string>
|
||||
<string name="healthchat_screen_member_directory_error_starting_chat">Failed to start the chat</string>
|
||||
<string name="healthchat_screen_member_directory_empty">There are no members yet.</string>
|
||||
<string name="healthchat_screen_member_directory_empty_search">No members match your search.</string>
|
||||
<string name="healthchat_screen_member_directory_unknown_name">Unknown</string>
|
||||
<string name="healthchat_screen_member_directory_pending_title">Account under review</string>
|
||||
<string name="healthchat_screen_member_directory_pending_message">Your account is currently under review. Once it has been approved you can see and search all members.</string>
|
||||
<string name="healthchat_a11y_refresh">Refresh</string>
|
||||
<string name="healthchat_a11y_edit_profile">Edit profile</string>
|
||||
<string name="healthchat_a11y_invite_member">Invite a new member</string>
|
||||
<string name="healthchat_field_name">Display name</string>
|
||||
<string name="healthchat_field_title">Title</string>
|
||||
<string name="healthchat_field_title_placeholder">e.g. Dr. med.</string>
|
||||
<string name="healthchat_field_first_name">First name</string>
|
||||
<string name="healthchat_field_last_name">Last name</string>
|
||||
<string name="healthchat_field_specialty_placeholder">e.g. general medicine</string>
|
||||
<string name="healthchat_field_practice_name_placeholder">e.g. Praxis Muster</string>
|
||||
<string name="healthchat_field_city">City</string>
|
||||
<string name="healthchat_field_city_placeholder">e.g. Zurich</string>
|
||||
<string name="healthchat_field_business_email_placeholder">praxis@example.ch</string>
|
||||
<string name="healthchat_field_business_tel_placeholder">+41 44 123 45 67</string>
|
||||
<string name="healthchat_field_street">Street</string>
|
||||
<string name="healthchat_field_house_number">No.</string>
|
||||
<string name="healthchat_field_postal_code">Postal code</string>
|
||||
<string name="healthchat_field_website_placeholder">https://praxis-muster.ch</string>
|
||||
<string name="healthchat_screen_edit_profile_title">Edit profile</string>
|
||||
<string name="healthchat_screen_edit_profile_section_personal">Personal details</string>
|
||||
<string name="healthchat_screen_edit_profile_section_professional">Professional details</string>
|
||||
<string name="healthchat_screen_edit_profile_section_contact">Contact details</string>
|
||||
<string name="healthchat_screen_edit_profile_section_address">Practice address</string>
|
||||
<string name="healthchat_screen_edit_profile_saving">Saving profile…</string>
|
||||
<string name="healthchat_screen_edit_profile_error_title">Couldn’t save the profile</string>
|
||||
<string name="healthchat_screen_edit_profile_load_error">The profile couldn’t be loaded.</string>
|
||||
<string name="healthchat_screen_edit_profile_recommended">Recommended for a complete profile</string>
|
||||
<string name="healthchat_error_field_required">This field is required</string>
|
||||
<string name="healthchat_error_invalid_email">Please enter a valid email address</string>
|
||||
<string name="healthchat_error_invalid_url">Please enter a valid URL (e.g. https://praxis-muster.ch)</string>
|
||||
<string name="healthchat_error_note_too_short">The personal message must be at least 10 characters long</string>
|
||||
<string name="healthchat_error_avatar_too_large">The image must not exceed 2 MB.</string>
|
||||
<string name="healthchat_banner_incomplete_title">Profile incomplete</string>
|
||||
<string name="healthchat_banner_incomplete_description">Please complete your profile so other members can find you. Missing details: %1$s</string>
|
||||
<string name="healthchat_banner_incomplete_action">Complete profile</string>
|
||||
<string name="healthchat_screen_invite_title">Invite a member</string>
|
||||
<string name="healthchat_screen_invite_description">Invite new members to HealthChat by entering their email address and a personal message.</string>
|
||||
<string name="healthchat_screen_invite_email_label">Email address</string>
|
||||
<string name="healthchat_screen_invite_email_placeholder">example@domain.ch</string>
|
||||
<string name="healthchat_screen_invite_note_label">Personal message</string>
|
||||
<string name="healthchat_screen_invite_note_placeholder">Write a personal message to the new member…</string>
|
||||
<string name="healthchat_screen_invite_note_counter">%1$d/10 characters</string>
|
||||
<string name="healthchat_screen_invite_send">Send invitation</string>
|
||||
<string name="healthchat_screen_invite_sending">Sending invitation…</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,81 @@
|
||||
import extension.buildConfigFieldStr
|
||||
import extension.readLocalProperty
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.healthchat.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
buildConfigFieldStr(
|
||||
name = "HEALTHCHAT_API_BASE_URL",
|
||||
value = System.getenv("HEALTHCHAT_API_BASE_URL")
|
||||
?: readLocalProperty("healthchat.api.base.url")
|
||||
?: "https://api.healthchat.ch",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.features.healthchat.api)
|
||||
implementation(libs.coroutines.core)
|
||||
implementation(projects.features.startchat.api)
|
||||
implementation(platform(libs.network.okhttp.bom))
|
||||
implementation(libs.network.okhttp.okhttp)
|
||||
implementation(libs.network.okhttp.logging)
|
||||
implementation(platform(libs.network.retrofit.bom))
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.network.retrofit.converter.serialization)
|
||||
implementation(libs.serialization.json)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.mediapickers.api)
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.network)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.sessionStorage.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(libs.network.mockwebserver)
|
||||
testImplementation(projects.features.healthchat.test)
|
||||
testImplementation(projects.features.startchat.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.libraries.preferences.api)
|
||||
testImplementation(projects.libraries.preferences.test)
|
||||
testImplementation(projects.libraries.sessionStorage.test)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.api.HealthChatDirectoryService
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
|
||||
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberDirectory
|
||||
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberOrNull
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultHealthChatDirectoryService(
|
||||
private val sessionApi: HealthChatSessionApi,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : HealthChatDirectoryService {
|
||||
override suspend fun searchMembers(query: String): Result<HealthChatMemberDirectory> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.getUsers(search = query.takeIf { it.isNotBlank() })
|
||||
}
|
||||
.map { it.toHealthChatMemberDirectory() }
|
||||
}
|
||||
|
||||
override suspend fun getMember(userId: UserId): Result<HealthChatMember> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.getUser(userId.value)
|
||||
}
|
||||
.flatMap { dto ->
|
||||
dto.toHealthChatMemberOrNull()
|
||||
?.let { Result.success(it) }
|
||||
?: Result.failure(
|
||||
HealthChatApiException.Unexpected(
|
||||
IllegalStateException("Member payload has no valid userId")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.healthchat.api.HealthChatEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultHealthChatEntryPoint : HealthChatEntryPoint {
|
||||
override fun createNode(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
params: HealthChatEntryPoint.Params,
|
||||
callback: HealthChatEntryPoint.Callback,
|
||||
): Node {
|
||||
return parentNode.createNode<HealthChatFlowNode>(buildContext, plugins = listOf(params, callback))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.healthchat.api.HealthChatInviteService
|
||||
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
|
||||
import io.element.android.features.healthchat.impl.network.InvitationRequestDto
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultHealthChatInviteService(
|
||||
private val sessionApi: HealthChatSessionApi,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : HealthChatInviteService {
|
||||
override suspend fun invite(email: String, personalNote: String): Result<String> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.invite(InvitationRequestDto(email = email, personalNote = personalNote))
|
||||
}
|
||||
.map { it.message.orEmpty() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.api.HealthChatCompleteness
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileRepository
|
||||
import io.element.android.features.healthchat.api.HealthChatProfileUpdate
|
||||
import io.element.android.features.healthchat.impl.mapper.runHealthChatApiCatching
|
||||
import io.element.android.features.healthchat.impl.mapper.toHealthChatMemberOrNull
|
||||
import io.element.android.features.healthchat.impl.network.toDto
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@SingleIn(SessionScope::class)
|
||||
class DefaultHealthChatProfileRepository(
|
||||
private val sessionApi: HealthChatSessionApi,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : HealthChatProfileRepository {
|
||||
private val ownProfileFlow = MutableStateFlow<AsyncData<HealthChatMember>>(AsyncData.Uninitialized)
|
||||
override val ownProfile: StateFlow<AsyncData<HealthChatMember>> = ownProfileFlow.asStateFlow()
|
||||
|
||||
private val completenessFlow = MutableStateFlow<AsyncData<HealthChatCompleteness>>(AsyncData.Uninitialized)
|
||||
override val completeness: StateFlow<AsyncData<HealthChatCompleteness>> = completenessFlow.asStateFlow()
|
||||
|
||||
override suspend fun refreshOwnProfile(): Result<HealthChatMember> {
|
||||
ownProfileFlow.value = AsyncData.Loading(ownProfileFlow.value.dataOrNull())
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.getProfile()
|
||||
}
|
||||
.flatMap { dto ->
|
||||
dto.toHealthChatMemberOrNull()
|
||||
?.let { Result.success(it) }
|
||||
?: Result.failure(
|
||||
HealthChatApiException.Unexpected(
|
||||
IllegalStateException("Own profile payload has no valid userId")
|
||||
)
|
||||
)
|
||||
}
|
||||
.onSuccess { ownProfileFlow.value = AsyncData.Success(it) }
|
||||
.onFailure { ownProfileFlow.value = AsyncData.Failure(it) }
|
||||
}
|
||||
|
||||
override suspend fun refreshCompleteness(): Result<HealthChatCompleteness> {
|
||||
completenessFlow.value = AsyncData.Loading(completenessFlow.value.dataOrNull())
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.getProfileCompleteness()
|
||||
}
|
||||
.map { dto ->
|
||||
HealthChatCompleteness(
|
||||
isComplete = dto.isComplete,
|
||||
missingFields = dto.missingFields.toImmutableList(),
|
||||
message = dto.message?.takeIf { it.isNotBlank() },
|
||||
hasProfilePhoto = dto.hasProfilePhoto,
|
||||
)
|
||||
}
|
||||
.onSuccess { completenessFlow.value = AsyncData.Success(it) }
|
||||
.onFailure { completenessFlow.value = AsyncData.Failure(it) }
|
||||
}
|
||||
|
||||
override suspend fun updateOwnProfile(update: HealthChatProfileUpdate): Result<String> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.updateProfile(update.toDto())
|
||||
}
|
||||
.map { it.message.orEmpty() }
|
||||
// Re-fetch so ownProfile reflects the server-derived state (e.g. display name).
|
||||
.onSuccess { refreshOwnProfile() }
|
||||
}
|
||||
|
||||
override suspend fun uploadAvatar(fileName: String, mimeType: String, data: ByteArray): Result<String> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
val part = MultipartBody.Part.createFormData(
|
||||
name = "file",
|
||||
filename = fileName,
|
||||
body = data.toRequestBody(mimeType.toMediaType()),
|
||||
)
|
||||
sessionApi.api.uploadAvatar(part)
|
||||
}
|
||||
.flatMap { dto ->
|
||||
dto.mxcUrl
|
||||
?.let { Result.success(it) }
|
||||
?: Result.failure(
|
||||
HealthChatApiException.Unexpected(
|
||||
IllegalStateException("Avatar upload response has no mxcUrl")
|
||||
)
|
||||
)
|
||||
}
|
||||
.onSuccess { refreshOwnProfile() }
|
||||
}
|
||||
|
||||
override suspend fun deleteAvatar(): Result<String> {
|
||||
return runHealthChatApiCatching(jsonProvider()) {
|
||||
sessionApi.api.deleteAvatar()
|
||||
}
|
||||
.map { it.message.orEmpty() }
|
||||
.onSuccess { refreshOwnProfile() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl
|
||||
|
||||
/**
|
||||
* HealthChat API configuration.
|
||||
*
|
||||
* The base URL defaults to production and can be overridden at build time via the
|
||||
* `HEALTHCHAT_API_BASE_URL` environment variable or the `healthchat.api.base.url` entry
|
||||
* in the (gitignored) `local.properties`, so a dev override never ships.
|
||||
*/
|
||||
internal object HealthChatApiConfig {
|
||||
val baseUrl: String = BuildConfig.HEALTHCHAT_API_BASE_URL
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.api.HealthChatStrings
|
||||
|
||||
/**
|
||||
* User-facing message for a HealthChat failure: the verbatim German server message where one
|
||||
* exists, localized fallbacks for local/unknown failures.
|
||||
*/
|
||||
@Composable
|
||||
fun Throwable.healthChatUserMessage(): String {
|
||||
return when (this) {
|
||||
is HealthChatApiException.Network -> stringResource(HealthChatStrings.healthchat_error_network)
|
||||
is HealthChatApiException.Unauthorized -> serverMessage
|
||||
is HealthChatApiException.NotApproved -> serverMessage
|
||||
is HealthChatApiException.NotFound -> serverMessage
|
||||
is HealthChatApiException.Http -> serverMessage
|
||||
else -> null
|
||||
} ?: stringResource(HealthChatStrings.healthchat_error_unknown)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.healthchat.api.HealthChatEntryPoint
|
||||
import io.element.android.features.healthchat.impl.invite.HealthChatInviteNode
|
||||
import io.element.android.features.healthchat.impl.profile.edit.EditHealthChatProfileNode
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@AssistedInject
|
||||
class HealthChatFlowNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BaseFlowNode<HealthChatFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins
|
||||
.filterIsInstance<HealthChatEntryPoint.Params>()
|
||||
.first()
|
||||
.initialTarget
|
||||
.toNavTarget(),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object EditOwnProfile : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Invite : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
// The HealthChatEntryPoint.Callback plugin is forwarded so the child screens can
|
||||
// close the whole flow.
|
||||
return when (navTarget) {
|
||||
NavTarget.EditOwnProfile -> createNode<EditHealthChatProfileNode>(buildContext, plugins = plugins)
|
||||
NavTarget.Invite -> createNode<HealthChatInviteNode>(buildContext, plugins = plugins)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
BackstackView()
|
||||
}
|
||||
}
|
||||
|
||||
private fun HealthChatEntryPoint.InitialTarget.toNavTarget(): HealthChatFlowNode.NavTarget {
|
||||
return when (this) {
|
||||
HealthChatEntryPoint.InitialTarget.EditOwnProfile -> HealthChatFlowNode.NavTarget.EditOwnProfile
|
||||
HealthChatEntryPoint.InitialTarget.Invite -> HealthChatFlowNode.NavTarget.Invite
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.healthchat.impl.network.HealthChatApi
|
||||
import io.element.android.features.healthchat.impl.network.HealthChatApiFactory
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Holds the single per-session [HealthChatApi] instance (one derived OkHttp client and
|
||||
* Retrofit instance per session, shared by all HealthChat services).
|
||||
*/
|
||||
@Inject
|
||||
@SingleIn(SessionScope::class)
|
||||
class HealthChatSessionApi(
|
||||
apiFactory: HealthChatApiFactory,
|
||||
sessionId: SessionId,
|
||||
) {
|
||||
val api: HealthChatApi by lazy {
|
||||
apiFactory.create(HealthChatApiConfig.baseUrl, sessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryRenderer
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
|
||||
import io.element.android.features.healthchat.impl.status.HealthChatAccountStatusPresenter
|
||||
import io.element.android.features.healthchat.impl.status.HealthChatAccountStatusView
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultMemberDirectoryRenderer(
|
||||
private val accountStatusPresenter: HealthChatAccountStatusPresenter,
|
||||
) : MemberDirectoryRenderer {
|
||||
@Composable
|
||||
override fun Render(
|
||||
state: MemberDirectoryState,
|
||||
contentPadding: PaddingValues,
|
||||
lazyListState: LazyListState,
|
||||
onOpenDm: (RoomId) -> Unit,
|
||||
onMemberClick: ((UserId) -> Unit)?,
|
||||
onMyProfileClick: (() -> Unit)?,
|
||||
onEditProfileClick: (() -> Unit)?,
|
||||
onInviteClick: (() -> Unit)?,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
// The "profile incomplete" advisory; the pending-approval case is excluded because
|
||||
// the directory itself shows the verbatim server pending message.
|
||||
if (onEditProfileClick != null) {
|
||||
HealthChatAccountStatusView(
|
||||
state = accountStatusPresenter.present(),
|
||||
onCompleteProfileClick = onEditProfileClick,
|
||||
includePendingApproval = false,
|
||||
)
|
||||
}
|
||||
MemberDirectoryView(
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
lazyListState = lazyListState,
|
||||
onOpenDm = onOpenDm,
|
||||
onMemberClick = onMemberClick,
|
||||
onMyProfileClick = onMyProfileClick,
|
||||
onEditProfileClick = onEditProfileClick,
|
||||
onInviteClick = onInviteClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory
|
||||
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.healthchat.api.HealthChatDirectoryService
|
||||
import io.element.android.features.healthchat.api.HealthChatMemberDirectory
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvent
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
|
||||
import io.element.android.features.startchat.api.StartDMAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Inject
|
||||
class MemberDirectoryPresenter(
|
||||
private val directoryService: HealthChatDirectoryService,
|
||||
private val startDMAction: StartDMAction,
|
||||
) : Presenter<MemberDirectoryState> {
|
||||
@Composable
|
||||
override fun present(): MemberDirectoryState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val searchQuery = rememberTextFieldState()
|
||||
// This presenter is composed from app start (by the Home presenter), so the first
|
||||
// fetch is deferred until the tab is actually shown (LoadIfNeeded from the view).
|
||||
var hasBeenRequested by rememberSaveable { mutableStateOf(false) }
|
||||
var loadTrigger by rememberSaveable { mutableIntStateOf(0) }
|
||||
var directory by remember { mutableStateOf<AsyncData<HealthChatMemberDirectory>>(AsyncData.Uninitialized) }
|
||||
val startDmActionState = remember { mutableStateOf<AsyncAction<RoomId>>(AsyncAction.Uninitialized) }
|
||||
|
||||
LaunchedEffect(hasBeenRequested, searchQuery.text, loadTrigger) {
|
||||
if (!hasBeenRequested) return@LaunchedEffect
|
||||
if (searchQuery.text.isNotEmpty()) {
|
||||
// Effect restart on typing = debounce.
|
||||
delay(300.milliseconds)
|
||||
}
|
||||
directory = AsyncData.Loading(directory.dataOrNull())
|
||||
directoryService.searchMembers(searchQuery.text.toString()).fold(
|
||||
onSuccess = { directory = AsyncData.Success(it) },
|
||||
// No stale fallback: a failed (re)load always shows the error state.
|
||||
onFailure = { directory = AsyncData.Failure(it) },
|
||||
)
|
||||
}
|
||||
|
||||
fun handleEvents(event: MemberDirectoryEvent) {
|
||||
when (event) {
|
||||
MemberDirectoryEvent.LoadIfNeeded -> {
|
||||
hasBeenRequested = true
|
||||
}
|
||||
MemberDirectoryEvent.Refresh -> {
|
||||
loadTrigger++
|
||||
}
|
||||
is MemberDirectoryEvent.StartDm -> coroutineScope.launch {
|
||||
startDMAction.execute(
|
||||
matrixUser = event.matrixUser,
|
||||
createIfDmDoesNotExist = startDmActionState.value is AsyncAction.Confirming,
|
||||
actionState = startDmActionState,
|
||||
)
|
||||
}
|
||||
MemberDirectoryEvent.ClearStartDmState -> {
|
||||
startDmActionState.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MemberDirectoryState(
|
||||
searchQuery = searchQuery,
|
||||
directory = directory,
|
||||
startDmActionState = startDmActionState.value,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.healthchat.api.HealthChatApiException
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
|
||||
import io.element.android.features.healthchat.api.directory.aMemberDirectory
|
||||
import io.element.android.features.healthchat.api.directory.aMemberDirectoryState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
|
||||
open class MemberDirectoryStatePreviewParam : PreviewParameterProvider<MemberDirectoryState> {
|
||||
override val values: Sequence<MemberDirectoryState>
|
||||
get() = sequenceOf(
|
||||
aMemberDirectoryState(),
|
||||
aMemberDirectoryState(directory = AsyncData.Uninitialized),
|
||||
aMemberDirectoryState(directory = AsyncData.Loading(aMemberDirectory())),
|
||||
aMemberDirectoryState(
|
||||
directory = AsyncData.Failure(
|
||||
HealthChatApiException.Http(500, "Fehler beim Laden der Benutzer")
|
||||
),
|
||||
),
|
||||
aMemberDirectoryState(
|
||||
directory = AsyncData.Success(
|
||||
aMemberDirectory(
|
||||
members = emptyList(),
|
||||
isApproved = false,
|
||||
pendingMessage = "Ihr Konto wird derzeit geprüft. Nach der Freigabe können Sie alle Mitglieder sehen und durchsuchen.",
|
||||
)
|
||||
),
|
||||
),
|
||||
aMemberDirectoryState(
|
||||
directory = AsyncData.Success(aMemberDirectory(members = emptyList())),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.healthchat.api.HealthChatStrings
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryEvent
|
||||
import io.element.android.features.healthchat.api.directory.MemberDirectoryState
|
||||
import io.element.android.features.healthchat.api.toMatrixUser
|
||||
import io.element.android.features.healthchat.impl.directory.components.MemberCard
|
||||
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryErrorContent
|
||||
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryHeader
|
||||
import io.element.android.features.healthchat.impl.directory.components.MemberDirectoryPendingBanner
|
||||
import io.element.android.features.healthchat.impl.healthChatUserMessage
|
||||
import io.element.android.features.startchat.api.ConfirmingStartDmWithMatrixUser
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchField
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.ui.components.CreateDmConfirmationBottomSheet
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun MemberDirectoryView(
|
||||
state: MemberDirectoryState,
|
||||
contentPadding: PaddingValues,
|
||||
lazyListState: LazyListState,
|
||||
onOpenDm: (RoomId) -> Unit,
|
||||
onMemberClick: ((UserId) -> Unit)?,
|
||||
onMyProfileClick: (() -> Unit)?,
|
||||
onEditProfileClick: (() -> Unit)?,
|
||||
onInviteClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
state.eventSink(MemberDirectoryEvent.LoadIfNeeded)
|
||||
}
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (val directory = state.directory) {
|
||||
AsyncData.Uninitialized -> FullScreenLoading()
|
||||
is AsyncData.Loading -> if (directory.prevData == null) {
|
||||
FullScreenLoading()
|
||||
} else {
|
||||
MemberDirectoryContent(
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
lazyListState = lazyListState,
|
||||
isRefreshing = true,
|
||||
onMemberClick = onMemberClick,
|
||||
onMyProfileClick = onMyProfileClick,
|
||||
onEditProfileClick = onEditProfileClick,
|
||||
onInviteClick = onInviteClick,
|
||||
)
|
||||
}
|
||||
is AsyncData.Failure -> MemberDirectoryErrorContent(
|
||||
message = directory.error.healthChatUserMessage(),
|
||||
onRetry = { state.eventSink(MemberDirectoryEvent.Refresh) },
|
||||
)
|
||||
is AsyncData.Success -> MemberDirectoryContent(
|
||||
state = state,
|
||||
contentPadding = contentPadding,
|
||||
lazyListState = lazyListState,
|
||||
isRefreshing = false,
|
||||
onMemberClick = onMemberClick,
|
||||
onMyProfileClick = onMyProfileClick,
|
||||
onEditProfileClick = onEditProfileClick,
|
||||
onInviteClick = onInviteClick,
|
||||
)
|
||||
}
|
||||
AsyncActionView(
|
||||
async = state.startDmActionState,
|
||||
progressDialog = {
|
||||
AsyncActionViewDefaults.ProgressDialog(
|
||||
progressText = stringResource(CommonStrings.common_starting_chat),
|
||||
)
|
||||
},
|
||||
onSuccess = onOpenDm,
|
||||
errorMessage = { stringResource(HealthChatStrings.healthchat_screen_member_directory_error_starting_chat) },
|
||||
onRetry = null,
|
||||
onErrorDismiss = { state.eventSink(MemberDirectoryEvent.ClearStartDmState) },
|
||||
confirmationDialog = { data ->
|
||||
if (data is ConfirmingStartDmWithMatrixUser) {
|
||||
CreateDmConfirmationBottomSheet(
|
||||
matrixUser = data.matrixUser,
|
||||
isUserIdentityUnknown = data.isUserIdentityUnknown,
|
||||
onSendInvite = {
|
||||
state.eventSink(MemberDirectoryEvent.StartDm(data.matrixUser))
|
||||
},
|
||||
onDismiss = {
|
||||
state.eventSink(MemberDirectoryEvent.ClearStartDmState)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberDirectoryContent(
|
||||
state: MemberDirectoryState,
|
||||
contentPadding: PaddingValues,
|
||||
lazyListState: LazyListState,
|
||||
isRefreshing: Boolean,
|
||||
onMemberClick: ((UserId) -> Unit)?,
|
||||
onMyProfileClick: (() -> Unit)?,
|
||||
onEditProfileClick: (() -> Unit)?,
|
||||
onInviteClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val directory = state.directory.dataOrNull() ?: return
|
||||
Column(modifier = modifier.fillMaxSize()) {
|
||||
if (isRefreshing) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
contentPadding = contentPadding,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item(key = "header") {
|
||||
MemberDirectoryHeader(
|
||||
memberCount = directory.totalCount,
|
||||
onMyProfileClick = onMyProfileClick,
|
||||
onEditProfileClick = onEditProfileClick,
|
||||
onInviteClick = if (state.isApproved) onInviteClick else null,
|
||||
onRefreshClick = { state.eventSink(MemberDirectoryEvent.Refresh) },
|
||||
)
|
||||
}
|
||||
if (!state.isApproved) {
|
||||
item(key = "pending") {
|
||||
MemberDirectoryPendingBanner(
|
||||
message = state.pendingMessage
|
||||
?: stringResource(HealthChatStrings.healthchat_screen_member_directory_pending_message),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item(key = "search") {
|
||||
SearchField(
|
||||
state = state.searchQuery,
|
||||
placeholder = stringResource(HealthChatStrings.healthchat_screen_member_directory_search_placeholder),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (directory.members.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
val message = if (state.searchQuery.text.isNotEmpty()) {
|
||||
stringResource(HealthChatStrings.healthchat_screen_member_directory_empty_search)
|
||||
} else {
|
||||
stringResource(HealthChatStrings.healthchat_screen_member_directory_empty)
|
||||
}
|
||||
Text(
|
||||
text = message,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(directory.members, key = { it.userId.value }) { member ->
|
||||
MemberCard(
|
||||
member = member,
|
||||
onClick = onMemberClick?.let { callback -> { callback(member.userId) } },
|
||||
onStartChatClick = { state.eventSink(MemberDirectoryEvent.StartDm(member.toMatrixUser())) },
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FullScreenLoading(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MemberDirectoryViewPreview(
|
||||
@PreviewParameter(MemberDirectoryStatePreviewParam::class) state: MemberDirectoryState
|
||||
) = ElementPreview {
|
||||
MemberDirectoryView(
|
||||
state = state,
|
||||
contentPadding = PaddingValues(),
|
||||
lazyListState = rememberLazyListState(),
|
||||
onOpenDm = {},
|
||||
onMemberClick = {},
|
||||
onMyProfileClick = {},
|
||||
onEditProfileClick = null,
|
||||
onInviteClick = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.healthchat.api.HealthChatMember
|
||||
import io.element.android.features.healthchat.api.HealthChatStrings
|
||||
import io.element.android.features.healthchat.api.bestDisplayName
|
||||
import io.element.android.features.healthchat.api.directory.aDirectoryMember
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.ButtonSize
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
|
||||
@Composable
|
||||
fun MemberCard(
|
||||
member: HealthChatMember,
|
||||
onClick: (() -> Unit)?,
|
||||
onStartChatClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.let { base -> onClick?.let { base.clickable(onClick = it) } ?: base },
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val displayName = member.bestDisplayName(
|
||||
fallback = stringResource(HealthChatStrings.healthchat_screen_member_directory_unknown_name),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Avatar(
|
||||
avatarData = AvatarData(
|
||||
id = member.userId.value,
|
||||
name = displayName,
|
||||
url = member.avatarUrl,
|
||||
size = AvatarSize.UserPreference,
|
||||
),
|
||||
avatarType = AvatarType.User,
|
||||
)
|
||||
Column(modifier = Modifier.padding(start = 12.dp)) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = ElementTheme.typography.fontBodyLgMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
member.specialty?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = ElementTheme.typography.fontBodySmMedium,
|
||||
color = ElementTheme.colors.textActionAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
member.practiceName?.let { MemberCardDetailRow(icon = CompoundIcons.Company(), text = it) }
|
||||
member.businessCity?.let { MemberCardDetailRow(icon = CompoundIcons.LocationPin(), text = it) }
|
||||
member.businessEmail?.let { MemberCardDetailRow(icon = CompoundIcons.Email(), text = it) }
|
||||
member.businessTel?.let { MemberCardDetailRow(icon = CompoundIcons.VoiceCall(), text = it) }
|
||||
Text(
|
||||
text = member.userId.value,
|
||||
style = ElementTheme.typography.fontBodyXsRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
OutlinedButton(
|
||||
text = stringResource(HealthChatStrings.healthchat_screen_member_directory_start_chat),
|
||||
onClick = onStartChatClick,
|
||||
size = ButtonSize.Small,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberCardDetailRow(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSecondary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MemberCardPreview() = ElementPreview {
|
||||
MemberCard(
|
||||
member = aDirectoryMember(),
|
||||
onClick = {},
|
||||
onStartChatClick = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun MemberDirectoryErrorContent(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
BigIcon(style = BigIcon.Style.AlertSolid)
|
||||
Text(
|
||||
text = message,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Button(
|
||||
text = stringResource(CommonStrings.action_retry),
|
||||
onClick = onRetry,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MemberDirectoryErrorContentPreview() = ElementPreview {
|
||||
MemberDirectoryErrorContent(
|
||||
message = "Fehler beim Laden der Benutzer",
|
||||
onRetry = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Novaloop AG
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.healthchat.impl.directory.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.healthchat.api.HealthChatStrings
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.features.healthchat.api.R as HealthChatR
|
||||
|
||||
@Composable
|
||||
fun MemberDirectoryHeader(
|
||||
memberCount: Int,
|
||||
onMyProfileClick: (() -> Unit)?,
|
||||
onEditProfileClick: (() -> Unit)?,
|
||||
onInviteClick: (() -> Unit)?,
|
||||
onRefreshClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
HealthChatR.plurals.healthchat_screen_member_directory_member_count,
|
||||
memberCount,
|
||||
memberCount,
|
||||
),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
onMyProfileClick?.let {
|
||||
TextButton(
|
||||
text = stringResource(HealthChatStrings.healthchat_screen_member_directory_my_profile),
|
||||
onClick = it,
|
||||
)
|
||||
}
|
||||
onEditProfileClick?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Edit(),
|
||||
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_edit_profile),
|
||||
)
|
||||
}
|
||||
}
|
||||
onInviteClick?.let {
|
||||
IconButton(onClick = it) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.UserAdd(),
|
||||
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_invite_member),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onRefreshClick) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Restart(),
|
||||
contentDescription = stringResource(HealthChatStrings.healthchat_a11y_refresh),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun MemberDirectoryHeaderPreview() = ElementPreview {
|
||||
MemberDirectoryHeader(
|
||||
memberCount = 42,
|
||||
onMyProfileClick = {},
|
||||
onEditProfileClick = null,
|
||||
onInviteClick = null,
|
||||
onRefreshClick = {},
|
||||
)
|
||||
}
|
||||