7 Commits

Author SHA1 Message Date
joel.poyet ada6fc457b feat(share): share user permalinks via to.healthchat.ch
Mention pills keep the matrix.to wire format other clients expect.
2026-08-03 10:53:51 +02:00
joel.poyet 57a2e585ba feat(deeplink): point deeplinks at HealthChat hosts
Claims app.healthchat.ch and to.healthchat.ch as App Links, registers the
healthchat:// scheme, and drops the unreachable mobile.element.io handling.
2026-08-03 10:53:35 +02:00
joel.poyet d6ea73140e docs(release): document self-hosted runner and heap sizing 2026-07-27 18:38:37 +02:00
joel.poyet a7e1217e82 fix(gplay): strip permissions Play Console rejects 2026-07-27 18:35:37 +02:00
joel.poyet 09b6d6341b ci(release): add HealthChat Play Store release pipeline 2026-07-27 18:35:36 +02:00
Sedat Qaja a1866b4b28 feat(branding): add HealthChat launcher icon and logo
Official cross-in-circle icon (same art as the iOS X app); onboarding
title localized for en/de/fr/it.
2026-07-16 14:13:00 +02:00
Sedat Qaja 2994430b88 feat(branding): rebrand app identity to HealthChat
App id ch.healthchat.element, locked matrix.healthchat.ch homeserver.
2026-07-16 11:03:52 +02:00
72 changed files with 932 additions and 215 deletions
@@ -0,0 +1,278 @@
---
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.
## 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 merging upstream
When merging a newer upstream Element X release tag into `release/healthchat`
(temporarily add `https://github.com/element-hq/element-x-android` as a
remote, fetch, merge, remove the remote — never keep a permanent upstream
remote):
- **`.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`** —
`defaultHomeserverList() = listOf("matrix.healthchat.ch")` must survive.
Upstream returns an empty list.
- **`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,241 @@
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 this container the
# daemon keeps getting SIGKILLed mid-build (DaemonDisappearedException, no
# OutOfMemoryError, no heap dump), so stay well under upstream's ceiling and
# leave room for the Kotlin daemon and the parallel lint workers.
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
runs-on: novaloop-incus-large
steps:
- uses: actions/checkout@v4
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
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VERSION="${{ github.event.inputs.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@v4
with:
distribution: 'temurin'
java-version: '21'
# The self-hosted runner image has no Android SDK (unlike GitHub-hosted
# ubuntu-latest), so install it here.
# - platform: follows COMPILE_SDK in plugins/src/main/kotlin/Versions.kt.
# Platform packages are minor-versioned, so compileSdk 37 is android-37.0.
# - build-tools: nothing sets buildToolsVersion, so AGP picks its own
# default (SdkConstants.CURRENT_BUILD_TOOLS_VERSION). Bump it when AGP is
# upgraded, not when Versions.kt changes.
- name: Set up Android SDK
uses: android-actions/setup-android@v3
with:
packages: 'platforms;android-37.0 build-tools;36.0.0'
- name: Configure gradle
uses: gradle/actions/setup-gradle@v4
- 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@v4
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@v1
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
changesNotSentForReview: true
+3
View File
@@ -82,6 +82,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/
+15
View File
@@ -0,0 +1,15 @@
# 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.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.
+20 -2
View File
@@ -96,6 +96,18 @@ android {
storePassword = System.getenv("ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD")
?: project.property("signing.element.nightly.storePassword") as? String?
}
// 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
@@ -122,7 +134,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
@@ -261,7 +279,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)
+63
View File
@@ -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>
+13 -27
View File
@@ -52,7 +52,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"
@@ -72,7 +72,7 @@
<category android:name="android.intent.category.DEFAULT" />
<data
android:host="open"
android:scheme="elementx" />
android:scheme="healthchat" />
</intent-filter>
<!--
OAuth redirection
@@ -86,7 +86,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" />
@@ -95,28 +97,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
@@ -130,7 +116,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" />
@@ -138,7 +124,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>
+9
View File
@@ -0,0 +1,9 @@
<?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.
-->
<resources>
<string name="screen_onboarding_welcome_title">"Willkommen bei HealthChat"</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<?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.
-->
<resources>
<string name="screen_onboarding_welcome_title">"Bienvenue chez HealthChat"</string>
</resources>
+9
View File
@@ -0,0 +1,9 @@
<?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.
-->
<resources>
<string name="screen_onboarding_welcome_title">"Benvenuto su HealthChat"</string>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?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.
-->
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- HealthChat override of the Element onboarding title ("Be in your element").
Overridden here app-side instead of editing the Localazy-managed login
strings, so upstream merges stay clean. Only the main HealthChat locales
are overridden; other locales fall back to their Element translation. -->
<string name="screen_onboarding_welcome_title" tools:ignore="MissingTranslation">"Welcome to HealthChat"</string>
</resources>
@@ -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"
}
@@ -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
}
+17
View File
@@ -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")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+40 -20
View File
@@ -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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

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
View File
@@ -1 +1 @@
Element X - Secure Chat & Call
HealthChat
@@ -24,7 +24,7 @@ class DefaultEnterpriseService : EnterpriseService {
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
override fun defaultHomeserverList(): List<String> = emptyList()
override fun defaultHomeserverList(): List<String> = listOf("matrix.healthchat.ch")
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit
@@ -25,9 +25,9 @@ class DefaultEnterpriseServiceTest {
}
@Test
fun `defaultHomeserverList should return empty list`() {
fun `defaultHomeserverList should return the HealthChat homeserver only`() {
val defaultEnterpriseService = DefaultEnterpriseService()
assertThat(defaultEnterpriseService.defaultHomeserverList()).isEmpty()
assertThat(defaultEnterpriseService.defaultHomeserverList()).containsExactly("matrix.healthchat.ch")
}
@Test
@@ -8,23 +8,18 @@
package io.element.android.features.login.impl
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.login.api.LoginIntentResolver
import io.element.android.features.login.api.LoginParams
/**
* HealthChat does not support mobile configuration links: the account provider is fixed to the
* HealthChat homeserver (see DefaultEnterpriseService.defaultHomeserverList), so there is nothing
* for such a link to configure. The matching intent-filter has been removed from the manifest as
* well, which means nothing can deliver such an intent in the first place.
*/
@ContributesBinding(AppScope::class)
class DefaultLoginIntentResolver : LoginIntentResolver {
override fun parse(uriString: String): LoginParams? {
val uri = uriString.toUri()
if (uri.host != "mobile.element.io") return null
if (uri.path.orEmpty().startsWith("/element").not()) return null
val accountProvider = uri.getQueryParameter("account_provider") ?: return null
val loginHint = uri.getQueryParameter("login_hint")
return LoginParams(
accountProvider = accountProvider,
loginHint = loginHint,
)
}
override fun parse(uriString: String): LoginParams? = null
}
@@ -9,72 +9,20 @@
package io.element.android.features.login.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.LoginParams
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class DefaultLoginIntentResolverTest : RobolectricTest() {
@Test
fun `nominal case`() {
fun `mobile configuration links are not supported`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = "mxid:@alice:example.org",
)
)
}
@Test
fun `extra unknown param`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org&extra=uknown"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = "mxid:@alice:example.org",
)
)
}
@Test
fun `no account provider`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element/?login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `no path`() {
fun `an arbitrary uri returns null`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `wrong path`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/wrong?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `wrong host`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://wrong.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org"
assertThat(sut.parse(uriString)).isNull()
}
@Test
fun `no login_hint param`() {
val sut = DefaultLoginIntentResolver()
val uriString = "https://mobile.element.io/element/?account_provider=example.org"
assertThat(sut.parse(uriString)).isEqualTo(
LoginParams(
accountProvider = "example.org",
loginHint = null,
)
)
assertThat(sut.parse("https://app.healthchat.ch/#/room/#room:healthchat.ch")).isNull()
}
}
@@ -33,7 +33,7 @@ class UserProfileNodeHelper(
context: Context,
permalinkBuilder: PermalinkBuilder,
) {
val permalinkResult = permalinkBuilder.permalinkForUser(userId)
val permalinkResult = permalinkBuilder.sharePermalinkForUser(userId)
permalinkResult.onSuccess { permalink ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
+6
View File
@@ -40,6 +40,12 @@ signing.element.nightly.storePassword=Secret
signing.element.nightly.keyId=Secret
signing.element.nightly.keyPassword=Secret
# Dummy values for the HealthChat release signing secrets, overridden by env
# vars in CI (see .github/workflows/healthchat-release-playstore.yml)
signing.healthchat.storePassword=Secret
signing.healthchat.keyId=Secret
signing.healthchat.keyPassword=Secret
# Customise the Lint version to use a more recent version than the one bundled with AGP
# https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html
# android.experimental.lint.version=8.12.2
@@ -8,5 +8,7 @@
package io.element.android.libraries.deeplink.impl
internal const val SCHEME = "elementx"
// Internal deep link, only used to build the notification PendingIntents. The "open" host keeps
// it distinct from the healthchat://room|user links handed out by the HealthChat matrix.to site.
internal const val SCHEME = "healthchat"
internal const val HOST = "open"
@@ -29,7 +29,7 @@ class DefaultInviteFriendsUseCase(
private val permalinkBuilder: PermalinkBuilder,
) : InviteFriendsUseCase {
override fun execute(activity: Activity) {
val permalinkResult = permalinkBuilder.permalinkForUser(matrixClient.sessionId)
val permalinkResult = permalinkBuilder.sharePermalinkForUser(matrixClient.sessionId)
permalinkResult.fold(
onSuccess = { permalink ->
val appName = buildMeta.applicationName
@@ -28,15 +28,15 @@ class DefaultDeepLinkCreatorTest {
val threadId = A_THREAD_ID
val eventId = AN_EVENT_ID
assertThat(sut.create(sessionId, null, null, null))
.isEqualTo("elementx://open/%40alice%3Aserver.org")
.isEqualTo("healthchat://open/%40alice%3Aserver.org")
assertThat(sut.create(sessionId, roomId, null, null))
.isEqualTo("elementx://open/%40alice%3Aserver.org/%21aRoomId%3Adomain")
.isEqualTo("healthchat://open/%40alice%3Aserver.org/%21aRoomId%3Adomain")
assertThat(sut.create(sessionId, roomId, threadId, null))
.isEqualTo("elementx://open/%40alice%3Aserver.org/%21aRoomId%3Adomain/%24aThreadId")
.isEqualTo("healthchat://open/%40alice%3Aserver.org/%21aRoomId%3Adomain/%24aThreadId")
assertThat(sut.create(sessionId, roomId, threadId, eventId))
.isEqualTo("elementx://open/%40alice%3Aserver.org/%21aRoomId%3Adomain/%24aThreadId/%24anEventId")
.isEqualTo("healthchat://open/%40alice%3Aserver.org/%21aRoomId%3Adomain/%24aThreadId/%24anEventId")
assertThat(sut.create(sessionId, roomId, null, eventId))
.isEqualTo("elementx://open/%40alice%3Aserver.org/%21aRoomId%3Adomain//%24anEventId")
.isEqualTo("healthchat://open/%40alice%3Aserver.org/%21aRoomId%3Adomain//%24anEventId")
}
@Test
@@ -47,12 +47,12 @@ class DefaultDeepLinkCreatorTest {
val threadId = ThreadId("\$a/ThreadId")
val eventId = EventId("\$an/EventId")
assertThat(sut.create(sessionId, roomId, null, null))
.isEqualTo("elementx://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain")
.isEqualTo("healthchat://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain")
assertThat(sut.create(sessionId, roomId, threadId, null))
.isEqualTo("elementx://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain/%24a%2FThreadId")
.isEqualTo("healthchat://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain/%24a%2FThreadId")
assertThat(sut.create(sessionId, roomId, threadId, eventId))
.isEqualTo("elementx://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain/%24a%2FThreadId/%24an%2FEventId")
.isEqualTo("healthchat://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain/%24a%2FThreadId/%24an%2FEventId")
assertThat(sut.create(sessionId, roomId, null, eventId))
.isEqualTo("elementx://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain//%24an%2FEventId")
.isEqualTo("healthchat://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain//%24an%2FEventId")
}
}
@@ -27,17 +27,17 @@ import org.junit.Test
class DefaultDeeplinkParserTest : RobolectricTest() {
companion object {
const val A_URI =
"elementx://open/@alice:server.org"
"healthchat://open/@alice:server.org"
const val A_URI_WITH_ROOM =
"elementx://open/@alice:server.org/!aRoomId:domain"
"healthchat://open/@alice:server.org/!aRoomId:domain"
const val A_URI_WITH_ROOM_WITH_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
"healthchat://open/@alice:server.org/!aRoomId:domain/\$aThreadId"
const val A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT =
"elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId"
"healthchat://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId"
const val A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD =
"elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId"
"healthchat://open/@alice:server.org/!aRoomId:domain//\$anEventId"
const val A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT_AND_INVALID_CHARACTERS =
"elementx://open/@a%2Flice:server.org/!a%2FRoomId:domain/\$a%2FThreadId/\$an%2FEventId"
"healthchat://open/@a%2Flice:server.org/!a%2FRoomId:domain/\$a%2FThreadId/\$an%2FEventId"
}
@Test
@@ -70,17 +70,17 @@ class DefaultDeeplinkParserTest : RobolectricTest() {
// Bad scheme
assertThat(sut.getFromIntent(createIntent("x://open/@alice:server.org"))).isNull()
// Bad host
assertThat(sut.getFromIntent(createIntent("elementx://close/@alice:server.org"))).isNull()
assertThat(sut.getFromIntent(createIntent("healthchat://close/@alice:server.org"))).isNull()
// No session Id
assertThat(sut.getFromIntent(createIntent("elementx://open"))).isNull()
assertThat(sut.getFromIntent(createIntent("healthchat://open"))).isNull()
assertThrowsInDebug {
// Invalid sessionId
sut.getFromIntent(createIntent("elementx://open/alice:server.org"))
sut.getFromIntent(createIntent("healthchat://open/alice:server.org"))
}
assertThrowsInDebug {
// Empty sessionId
sut.getFromIntent(createIntent("elementx://open//"))
sut.getFromIntent(createIntent("healthchat://open//"))
}
}
@@ -14,6 +14,16 @@ import io.element.android.libraries.matrix.api.core.UserId
interface PermalinkBuilder {
fun permalinkForUser(userId: UserId): Result<String>
fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String>
/**
* Permalink meant to be shared outside of Matrix, for instance through the system share sheet.
* It points to the HealthChat matrix.to deployment, so that recipients who do not have the app
* yet are offered HealthChat rather than Element.
*
* Links embedded in events (mention pills, replies, …) must keep using [permalinkForUser]:
* matrix.to is the format other Matrix clients expect on the wire.
*/
fun sharePermalinkForUser(userId: UserId): Result<String>
}
sealed class PermalinkBuilderError : Throwable() {
@@ -22,25 +22,29 @@ import io.element.android.libraries.matrix.api.permalink.MatrixToConverter
@ContributesBinding(AppScope::class)
class DefaultMatrixToConverter : MatrixToConverter {
/**
* Try to convert a URL from an element web instance or from a client permalink to a matrix.to url.
* Try to convert a URL from a web client instance or from a client permalink to a matrix.to url.
* To be successfully converted, URL path should contain one of the [SUPPORTED_PATHS].
* Examples:
* - https://riot.im/develop/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - https://app.element.io/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - https://app.healthchat.ch/#/room/#room:healthchat.ch -> https://matrix.to/#/#room:healthchat.ch
* - https://www.example.org/#/room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* Also convert links coming from the matrix.to website:
* - element://room/#element-android:matrix.org -> https://matrix.to/#/#element-android:matrix.org
* - element://user/@alice:matrix.org -> https://matrix.to/#/@alice:matrix.org
* Also convert links coming from the HealthChat matrix.to website, which uses the matrix.to URL
* format on its own host and hands out a custom scheme to Android:
* - https://to.healthchat.ch/#/#room:healthchat.ch -> https://matrix.to/#/#room:healthchat.ch
* - healthchat://room/#room:healthchat.ch -> https://matrix.to/#/#room:healthchat.ch
* - healthchat://user/@alice:healthchat.ch -> https://matrix.to/#/@alice:healthchat.ch
*/
override fun convert(uri: Uri): Uri? {
val uriString = uri.toString()
// Handle links coming from the matrix.to website.
.replacePrefix(MATRIX_TO_CUSTOM_SCHEME_BASE_URL, "https://app.element.io/#/")
val baseUrl = MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL
val uriString = uri.toString()
// Handle the custom scheme handed out by the HealthChat matrix.to website.
.replacePrefix(MATRIX_TO_CUSTOM_SCHEME_BASE_URL, MatrixConfiguration.HEALTHCHAT_WEB_BASE_URL)
// Our own matrix.to deployment already uses the matrix.to URL format.
.replacePrefix(MatrixConfiguration.HEALTHCHAT_TO_PERMALINK_BASE_URL, baseUrl)
return when {
// URL is already a matrix.to
uriString.startsWith(baseUrl) -> uri
uriString.startsWith(baseUrl) -> uriString.toUri()
// Web or client url
SUPPORTED_PATHS.any { it in uriString } -> {
val path = SUPPORTED_PATHS.first { it in uriString }
@@ -52,7 +56,7 @@ class DefaultMatrixToConverter : MatrixToConverter {
}
companion object {
private const val MATRIX_TO_CUSTOM_SCHEME_BASE_URL = "element://"
private const val MATRIX_TO_CUSTOM_SCHEME_BASE_URL = "healthchat://"
private val SUPPORTED_PATHS = listOf(
"/#/room/",
"/#/user/",
@@ -10,6 +10,8 @@ package io.element.android.libraries.matrix.impl.permalink
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.appconfig.MatrixConfiguration
import io.element.android.libraries.core.extensions.replacePrefix
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.matrix.api.core.RoomAlias
@@ -38,4 +40,13 @@ class DefaultPermalinkBuilder : PermalinkBuilder {
matrixToRoomAliasPermalink(roomAlias.value)
}
}
override fun sharePermalinkForUser(userId: UserId): Result<String> {
return permalinkForUser(userId).map { permalink ->
permalink.replacePrefix(
MatrixConfiguration.MATRIX_TO_PERMALINK_BASE_URL,
MatrixConfiguration.HEALTHCHAT_TO_PERMALINK_BASE_URL,
)
}
}
}
@@ -44,15 +44,46 @@ class DefaultMatrixToConverterTest : RobolectricTest() {
assertThat(DefaultMatrixToConverter().convert(url)).isNull()
}
@Test
fun `converting the root url of the HealthChat matrix-to website returns null`() {
val url = Uri.parse("https://to.healthchat.ch/")
assertThat(DefaultMatrixToConverter().convert(url)).isNull()
}
@Test
fun `converting a url from the HealthChat matrix-to website returns a matrix-to url for room case`() {
val url = Uri.parse("https://to.healthchat.ch/#/#room:healthchat.ch")
assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#room:healthchat.ch"))
}
@Test
fun `converting a url from the HealthChat matrix-to website returns a matrix-to url for user case`() {
val url = Uri.parse("https://to.healthchat.ch/#/@alice:healthchat.ch")
assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@alice:healthchat.ch"))
}
@Test
fun `converting a url from the HealthChat web client returns a matrix-to url`() {
val url = Uri.parse("https://app.healthchat.ch/#/room/%23room%3Ahealthchat.ch")
assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/%23room%3Ahealthchat.ch"))
}
@Test
fun `converting url coming from the matrix-to website returns a matrix-to url for room case`() {
val url = Uri.parse("element://room/#element-android:matrix.org")
val url = Uri.parse("healthchat://room/#element-android:matrix.org")
assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/#element-android:matrix.org"))
}
@Test
fun `converting url coming from the matrix-to website returns a matrix-to url for user case`() {
val url = Uri.parse("element://user/@alice:matrix.org")
val url = Uri.parse("healthchat://user/@alice:matrix.org")
assertThat(DefaultMatrixToConverter().convert(url)).isEqualTo(Uri.parse("https://matrix.to/#/@alice:matrix.org"))
}
@Test
fun `converting an event url coming from the matrix-to website keeps the event id and the via params`() {
val url = Uri.parse("healthchat://room/%23room%3Ahealthchat.ch/%24eventId?via=healthchat.ch")
assertThat(DefaultMatrixToConverter().convert(url))
.isEqualTo(Uri.parse("https://matrix.to/#/%23room%3Ahealthchat.ch/%24eventId?via=healthchat.ch"))
}
}
@@ -16,6 +16,7 @@ import io.element.android.tests.testutils.lambda.lambdaError
class FakePermalinkBuilder(
private val permalinkForUserLambda: (UserId) -> Result<String> = { lambdaError() },
private val permalinkForRoomAliasLambda: (RoomAlias) -> Result<String> = { lambdaError() },
private val sharePermalinkForUserLambda: (UserId) -> Result<String> = { lambdaError() },
) : PermalinkBuilder {
override fun permalinkForUser(userId: UserId): Result<String> {
return permalinkForUserLambda(userId)
@@ -24,4 +25,8 @@ class FakePermalinkBuilder(
override fun permalinkForRoomAlias(roomAlias: RoomAlias): Result<String> {
return permalinkForRoomAliasLambda(roomAlias)
}
override fun sharePermalinkForUser(userId: UserId): Result<String> {
return sharePermalinkForUserLambda(userId)
}
}
@@ -9,19 +9,24 @@
package config
object BuildTimeConfig {
const val APPLICATION_ID = "io.element.android.x"
const val APPLICATION_NAME = "Element X"
const val APPLICATION_ID = "ch.healthchat.element"
const val APPLICATION_NAME = "HealthChat"
const val GOOGLE_APP_ID_RELEASE = "1:912726360885:android:d097de99a4c23d2700427c"
const val GOOGLE_APP_ID_DEBUG = "1:912726360885:android:def0a4e454042e9b00427c"
const val GOOGLE_APP_ID_NIGHTLY = "1:912726360885:android:e17435e0beb0303000427c"
val METADATA_HOST_REVERSED: String? = null
val URL_WEBSITE: String? = null
val URL_LOGO: String? = null
// OIDC redirect scheme base. MAS requires the custom scheme to be the
// reverse-DNS of the CLIENT_URI (URL_WEBSITE) host, or an extension of it.
val METADATA_HOST_REVERSED: String? = "ch.healthchat.element"
// Must stay the apex host: the OIDC client_uri host must reverse-match
// METADATA_HOST_REVERSED, and logo/tos/policy URIs must be on this host
// or a subdomain of it.
val URL_WEBSITE: String? = "https://healthchat.ch"
val URL_LOGO: String? = "https://www.healthchat.ch/favicon.ico"
val URL_COPYRIGHT: String? = null
val URL_ACCEPTABLE_USE: String? = null
val URL_PRIVACY: String? = null
val URL_POLICY: String? = null
val URL_ACCEPTABLE_USE: String? = "https://www.healthchat.ch/datenschutz"
val URL_PRIVACY: String? = "https://www.healthchat.ch/datenschutz"
val URL_POLICY: String? = "https://www.healthchat.ch/datenschutz"
val SERVICES_MAPTILER_BASE_URL: String? = null
val SERVICES_MAPTILER_APIKEY: String? = null
val SERVICES_MAPTILER_LIGHT_MAPID: String? = null
+1
View File
@@ -44,6 +44,7 @@ include(":appnav")
include(":appconfig")
include(":appicon:element")
include(":appicon:enterprise")
include(":appicon:healthchat")
include(":tests:detekt-rules")
include(":tests:konsist")
include(":tests:uitests")
+6 -6
View File
@@ -7,13 +7,13 @@
# Please see LICENSE files in the repository root for full details.
# Format is:
# elementx://open/{sessionId} to open a session
# elementx://open/{sessionId}/{roomId} to open a room
# elementx://open/{sessionId}/{roomId}/{eventId} to open a thread
# healthchat://open/{sessionId} to open a session
# healthchat://open/{sessionId}/{roomId} to open a room
# healthchat://open/{sessionId}/{roomId}/{eventId} to open a thread
# Open a session
# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org
# adb shell am start -a android.intent.action.VIEW -d healthchat://open/@benoit10518:matrix.org
# Open a room
adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org
adb shell am start -a android.intent.action.VIEW -d healthchat://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org
# Open a thread
# adb shell am start -a android.intent.action.VIEW -d elementx://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org/\\\$threadId
# adb shell am start -a android.intent.action.VIEW -d healthchat://open/@benoit10518:matrix.org/!dehdDVSkabQLZFYrgo:matrix.org/\\\$threadId
@@ -6,4 +6,4 @@
# Please see LICENSE files in the repository root for full details.
adb shell am start -a android.intent.action.VIEW \
-d "element://room/%23element-android%3Amatrix.org"
-d "healthchat://room/%23healthchat%3Ahealthchat.ch"
+1 -1
View File
@@ -7,4 +7,4 @@
# Please see LICENSE files in the repository root for full details.
adb shell am start -a android.intent.action.VIEW -c android.intent.category.BROWSABLE \
-d "https://app.element.io/#/room/!cuqHozLHNBgupgLMKN:matrix.org/%24LZDOueY3R8OD2ZYf8FLKtu95aF7imLBC3F5TIUj-4cc"
-d "https://app.healthchat.ch/#/room/!cuqHozLHNBgupgLMKN:healthchat.ch/%24LZDOueY3R8OD2ZYf8FLKtu95aF7imLBC3F5TIUj-4cc"
+1 -1
View File
@@ -7,4 +7,4 @@
# Please see LICENSE files in the repository root for full details.
adb shell am start -a android.intent.action.VIEW \
-d "https://matrix.to/#/#matrix:matrix.org"
-d "https://to.healthchat.ch/#/#healthchat:healthchat.ch"
-13
View File
@@ -1,13 +0,0 @@
#! /bin/bash
# Copyright (c) 2025 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
# Please see LICENSE files in the repository root for full details.
# Format is:
# https://mobile.element.io/element/?account_provider=example.org&login_hint=mxid:@alice:example.org
adb shell am start -a android.intent.action.VIEW \
-d "https://mobile.element.io/element/?account_provider=element.io\\&login_hint=mxid:@alice:element.io"