Compare commits
59 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a5671d6d7 | |||
| 76f071c467 | |||
| 04fc967cbb | |||
| 2c039fc535 | |||
| 36fe1c1e8a | |||
| 5f7613ddac | |||
| e710e7d669 | |||
| d25549fcc9 | |||
| b61ebd2f11 | |||
| de2edafe61 | |||
| a944499eda | |||
| 0ef6b69a79 | |||
| 84519ab6c9 | |||
| 2d8df4f23f | |||
| a57fd79098 | |||
| af05e51916 | |||
| dde0dd9f4f | |||
| d975d7d761 | |||
| 2b93236229 | |||
| c35289a3bd | |||
| 699807e1bd | |||
| faa6f768f6 | |||
| ee439cb5a3 | |||
| da589ae78f | |||
| 75edbd5499 | |||
| 1308a8299a | |||
| 0388cd7d06 | |||
| 86d6686aee | |||
| f56f124a39 | |||
| c1b927380f | |||
| bf3ad49bec | |||
| efcc9cb841 | |||
| 9613a1e6fc | |||
| 9e9192dd3b | |||
| 02ecbfda83 | |||
| c21a3b7c48 | |||
| 1dbc4c92c4 | |||
| 455f45ed59 | |||
| e33c87c164 | |||
| b867fa783e | |||
| 0113f65c7a | |||
| ad89eddfea | |||
| c722ecb3a7 | |||
| feb99a2518 | |||
| bd883e9c3a | |||
| b12b1e4770 | |||
| a9c05a2b66 | |||
| 31d4537a71 | |||
| 11ebaf5042 | |||
| 06a9c6b0d2 | |||
| f2b95d6b8a | |||
| adee67cf0d | |||
| 39561e1aeb | |||
| 9439f5a227 | |||
| 19637833a6 | |||
| db4c262b27 | |||
| 880454847e | |||
| 9ff2b0964a | |||
| 225afc3108 |
171 changed files with 15324 additions and 2039 deletions
40
.forgejo/workflows/gitleaks.yml
Normal file
40
.forgejo/workflows/gitleaks.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# .forgejo/workflows/gitleaks.yml
|
||||
#
|
||||
# Sulkta canonical gitleaks workflow. Drop a copy into every public repo at
|
||||
# `.forgejo/workflows/gitleaks.yml` after the Forgejo act_runner is registered
|
||||
# (task #295).
|
||||
#
|
||||
# Pairs with the pre-receive hook installed on every bare repo — that one is
|
||||
# the strict enforcement layer (rejects the push); this one provides the
|
||||
# per-PR red ✗ that branch-protection rules can require before merge.
|
||||
#
|
||||
# Layer 1 (this workflow): visible per-PR status, can be a required check.
|
||||
# Layer 2 (pre-receive hook): strict enforcement at the server.
|
||||
# Layer 3 (johnny5 cron sweep): nightly full-history sweep across all repos.
|
||||
|
||||
name: gitleaks
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history — gitleaks needs depth to scan a commit range.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: install gitleaks
|
||||
run: |
|
||||
curl -sSL -o gl.tar.gz \
|
||||
https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz
|
||||
tar xzf gl.tar.gz gitleaks
|
||||
chmod +x gitleaks
|
||||
./gitleaks version
|
||||
|
||||
- name: scan
|
||||
run: |
|
||||
./gitleaks detect --source . --no-banner --redact --verbose
|
||||
116
.gitea/workflows/upstream-sync.yml
Normal file
116
.gitea/workflows/upstream-sync.yml
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
name: Upstream sync
|
||||
|
||||
# Daily check against the upstream mirror. Fast-forwards `main` to
|
||||
# `upstream/develop` when upstream has advanced, then pings the Infra
|
||||
# Matrix room so we know the wallet branch is due for a rebase.
|
||||
#
|
||||
# See SYNC.md on the wallet branch for the full topology + procedure
|
||||
# this job implements.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 12:00 UTC daily — quiet time for all our time zones, avoids the
|
||||
# morning-meeting window where an unexpected Matrix ping is noise.
|
||||
- cron: '0 12 * * *'
|
||||
workflow_dispatch: # manual trigger from the Actions UI too
|
||||
|
||||
jobs:
|
||||
sync-main:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The repo's .gitattributes (inherited from upstream) routes the
|
||||
# screenshots/ tree through git-lfs. Gitea's LFS store doesn't hold
|
||||
# those blobs, so on checkout the smudge filter tries to 404-download
|
||||
# them and wedges git state for subsequent fetches. We don't need
|
||||
# the image bytes here — leave LFS pointers as-is.
|
||||
GIT_LFS_SKIP_SMUDGE: '1'
|
||||
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
lfs: false
|
||||
# Gitea's built-in GITEA_TOKEN is read-only by default.
|
||||
# GIT_PUSH_TOKEN is a repo secret with a write-scoped PAT, so
|
||||
# the subsequent `git push origin main` actually lands.
|
||||
token: ${{ secrets.GIT_PUSH_TOKEN }}
|
||||
|
||||
- name: Fetch upstream + wallet
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Fetch directly from GitHub. We also have a Gitea pull-mirror
|
||||
# at Sulkta-Coop/element-x-upstream that tracks this same repo,
|
||||
# but sourcing from GitHub keeps the workflow independent of
|
||||
# the mirror's health — one less moving part to diagnose.
|
||||
git remote add upstream https://github.com/element-hq/element-x-android.git
|
||||
git fetch --depth=500 upstream develop
|
||||
git fetch origin wallet:refs/remotes/origin/wallet
|
||||
|
||||
- name: Fast-forward main
|
||||
id: ff
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "sulkta-bot"
|
||||
git config user.email "bot@sulkta.com"
|
||||
# git-lfs pre-push hook refuses incomplete pushes — which triggers
|
||||
# here because we skipped LFS smudge on checkout, so local LFS
|
||||
# objects are absent. We're only pushing branch pointers (no new
|
||||
# LFS content), so allow incomplete.
|
||||
git config lfs.allowincompletepush true
|
||||
OLD=$(git rev-parse --short HEAD)
|
||||
echo "main was at $OLD"
|
||||
if git merge --ff-only upstream/develop; then
|
||||
NEW=$(git rev-parse --short HEAD)
|
||||
if [ "$OLD" = "$NEW" ]; then
|
||||
echo "main already up to date with upstream/develop"
|
||||
echo "advanced=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "main advanced: $OLD -> $NEW"
|
||||
git push origin main
|
||||
echo "advanced=true" >> "$GITHUB_OUTPUT"
|
||||
echo "old=$OLD" >> "$GITHUB_OUTPUT"
|
||||
echo "new=$NEW" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "::warning::main could not fast-forward to upstream/develop — someone committed to main directly?"
|
||||
echo "advanced=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Measure wallet drift
|
||||
if: steps.ff.outputs.advanced == 'true'
|
||||
id: drift
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MB=$(git merge-base refs/remotes/origin/wallet main)
|
||||
BEHIND=$(git rev-list --count "$MB..main")
|
||||
NEW_ADDED=$(git rev-list --count "$MB..upstream/develop")
|
||||
echo "behind=$BEHIND" >> "$GITHUB_OUTPUT"
|
||||
echo "new_added=$NEW_ADDED" >> "$GITHUB_OUTPUT"
|
||||
echo "wallet is $BEHIND commits behind main now; $NEW_ADDED new upstream commits this run"
|
||||
|
||||
- name: Matrix notification (Infra room)
|
||||
# Best-effort — if the target bot isn't in the room or Matrix is
|
||||
# flapping, don't fail the whole run. The advance + push is the
|
||||
# critical path; notify is a convenience ping.
|
||||
if: steps.ff.outputs.advanced == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
MATRIX_TOKEN: ${{ secrets.MATRIX_HOUSE_BOT_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TXN=$(date +%s%N)
|
||||
ROOM='!rvxiUrWpgvMTAwzjGm:sulkta.com' # Infra
|
||||
BODY="element-x upstream advanced · main ${{ steps.ff.outputs.old }} → ${{ steps.ff.outputs.new }} (${{ steps.drift.outputs.new_added }} commits). wallet is ${{ steps.drift.outputs.behind }} commits behind — rebase before next build."
|
||||
|
||||
# jq keeps the body properly JSON-escaped; safer than shell interp
|
||||
# shellcheck disable=SC2086
|
||||
PAYLOAD=$(printf '%s' "$BODY" | jq -Rs '{msgtype: "m.text", body: .}')
|
||||
|
||||
curl --fail -s -X PUT \
|
||||
-H "Authorization: Bearer $MATRIX_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://chat.sulkta.com/_matrix/client/v3/rooms/${ROOM}/send/m.room.message/${TXN}" \
|
||||
-d "$PAYLOAD"
|
||||
echo "notified"
|
||||
110
.github/workflows/build.yml
vendored
110
.github/workflows/build.yml
vendored
|
|
@ -1,110 +0,0 @@
|
|||
name: APK Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build APKs
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# For NejcZdovc/comment-pr
|
||||
pull-requests: write
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [debug, release, nightly]
|
||||
fail-fast: false
|
||||
# Allow all jobs on develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-{0}-{1}', matrix.variant, github.sha) || format('build-{0}-{1}', matrix.variant, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APKs
|
||||
if: ${{ matrix.variant == 'debug' }}
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload debug APKs
|
||||
if: ${{ matrix.variant == 'debug' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: elementx-debug
|
||||
path: |
|
||||
app/build/outputs/apk/gplay/debug/*-universal-debug.apk
|
||||
app/build/outputs/apk/fdroid/debug/*-universal-debug.apk
|
||||
- uses: rnkdsh/action-upload-diawi@4e1421305be7cfc510d05f47850262eeaf345108 # v1.5.12
|
||||
id: diawi
|
||||
# Do not fail the whole build if Diawi upload fails
|
||||
continue-on-error: true
|
||||
env:
|
||||
token: ${{ secrets.DIAWI_TOKEN }}
|
||||
if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && env.token != '' }}
|
||||
with:
|
||||
token: ${{ env.token }}
|
||||
file: app/build/outputs/apk/gplay/debug/app-gplay-arm64-v8a-debug.apk
|
||||
- name: Add or update PR comment with QR Code to download APK.
|
||||
if: ${{ matrix.variant == 'debug' && github.event_name == 'pull_request' && steps.diawi.conclusion == 'success' }}
|
||||
uses: NejcZdovc/comment-pr@a423635d183a8259308e80593c96fecf31539c26 # v2.1.0
|
||||
with:
|
||||
message: |
|
||||
:iphone: Scan the QR code below to install the build (arm64 only) for this PR.
|
||||

|
||||
If you can't scan the QR code you can install the build via this link: ${{ steps.diawi.outputs['url'] }}
|
||||
# Enables to identify and update existing Ad-hoc release message on new commit in the PR
|
||||
identifier: "GITHUB_COMMENT_QR_CODE"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Compile release sources
|
||||
if: ${{ matrix.variant == 'release' }}
|
||||
run: ./gradlew bundleGplayRelease -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Compile nightly sources
|
||||
if: ${{ matrix.variant == 'nightly' }}
|
||||
run: ./gradlew compileGplayNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
92
.github/workflows/build_enterprise.yml
vendored
92
.github/workflows/build_enterprise.yml
vendored
|
|
@ -1,92 +0,0 @@
|
|||
name: Enterprise APK Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Enterprise APKs
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [debug, release, nightly]
|
||||
fail-fast: false
|
||||
# Allow all jobs on develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/develop' && format('build-develop-enterprise-{0}-{1}', matrix.variant, github.sha) || format('build-enterprise-{0}-{1}', matrix.variant, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug Gplay Enterprise APK
|
||||
if: ${{ matrix.variant == 'debug' }}
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload debug Enterprise APKs
|
||||
if: ${{ matrix.variant == 'debug' }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: elementx-enterprise-debug
|
||||
path: |
|
||||
app/build/outputs/apk/gplay/debug/*-universal-debug.apk
|
||||
- name: Compile nightly and release sources
|
||||
if: ${{ matrix.variant == 'release' }}
|
||||
run: ./gradlew compileReleaseSources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Compile nightly sources
|
||||
if: ${{ matrix.variant == 'nightly' }}
|
||||
run: ./gradlew compileGplayNightlySources -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
33
.github/workflows/danger.yml
vendored
33
.github/workflows/danger.yml
vendored
|
|
@ -1,33 +0,0 @@
|
|||
name: Danger CI
|
||||
|
||||
on: [pull_request, merge_group]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Danger main check
|
||||
# Skip in forks, it doesn't work even with the fallback token
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- run: |
|
||||
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
||||
- name: Danger
|
||||
uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile.js"
|
||||
env:
|
||||
DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
|
||||
# Fallback for forks
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
38
.github/workflows/fork-pr-notice.yml
vendored
38
.github/workflows/fork-pr-notice.yml
vendored
|
|
@ -1,38 +0,0 @@
|
|||
name: Community PR notice
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
welcome:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Require to comment the PR.
|
||||
pull-requests: write
|
||||
name: Welcome comment
|
||||
# Only display it if base repo (upstream) is different from HEAD repo (possibly a fork)
|
||||
if: github.event.pull_request.base.repo.full_name != github.event.pull_request.head.repo.full_name
|
||||
steps:
|
||||
- name: Add auto-generated commit warning
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `Thank you for your contribution! Here are a few things to check in the PR to ensure it's reviewed as quickly as possible:
|
||||
|
||||
- If your pull request adds a feature or modifies the UI, this should have an equivalent pull request in the [Element X iOS repo](https://github.com/element-hq/element-x-ios) unless it only affects an Android-only behaviour or is behind a disabled feature flag, since we need parity in both clients to consider a feature done. It will also need to be approved by our product and design teams before being merged, so it's usually a good idea to discuss the changes in a Github issue first and then start working on them once the approach has been validated.
|
||||
- Your branch should be based on \`origin/develop\`, at least when it was created.
|
||||
- The title of the PR will be used for release notes, so it needs to describe the change visible to the user.
|
||||
- The test pass locally running \`./gradlew test\`.
|
||||
- The code quality check suite pass locally running \`./gradlew runQualityChecks\`.
|
||||
- If you modified anything related to the UI, including previews, you'll have to run the \`Record screenshots\` GH action in your forked repo: that will generate compatible new screenshots. However, given Github Actions limitations, **it will prevent the CI from running temporarily**, until you upload a new commit after that one. To do so, just pull the latest changes and push [an empty commit](https://coderwall.com/p/vkdekq/git-commit-allow-empty).`
|
||||
})
|
||||
42
.github/workflows/generate_github_pages.yml
vendored
42
.github/workflows/generate_github_pages.yml
vendored
|
|
@ -1,42 +0,0 @@
|
|||
name: Generate GitHub Pages
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# At 00:00 on every Tuesday UTC
|
||||
- cron: '0 0 * * 2'
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
generate-github-pages:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.14
|
||||
- name: Run World screenshots generation script
|
||||
run: |
|
||||
./tools/test/generateWorldScreenshots.py
|
||||
mkdir -p screenshots/en
|
||||
cp tests/uitests/src/test/snapshots/images/* screenshots/en
|
||||
- name: Deploy GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./screenshots
|
||||
30
.github/workflows/gradle-wrapper-update.yml
vendored
30
.github/workflows/gradle-wrapper-update.yml
vendored
|
|
@ -1,30 +0,0 @@
|
|||
name: Update Gradle Wrapper
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
update-gradle-wrapper:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
name: Use JDK 21
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Update Gradle Wrapper
|
||||
uses: gradle-update/update-gradle-wrapper-action@512b1875f3b6270828abfe77b247d5895a2da1e5 # v2.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
|
||||
target-branch: develop
|
||||
labels: PR-Build
|
||||
149
.github/workflows/maestro-local.yml
vendored
149
.github/workflows/maestro-local.yml
vendored
|
|
@ -1,149 +0,0 @@
|
|||
name: Maestro (local)
|
||||
|
||||
# Run this flow only when APK Build workflow completes
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
ARCH: x86_64
|
||||
DEVICE: pixel_7_pro
|
||||
API_LEVEL: 33
|
||||
TARGET: google_apis
|
||||
|
||||
jobs:
|
||||
build-apk:
|
||||
name: Build APK
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ format('maestro-build-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.ref }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
name: Use JDK 21
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Assemble debug APK
|
||||
run: ./gradlew :app:assembleGplayDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
- name: Upload APK as artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: elementx-apk-maestro
|
||||
path: |
|
||||
app/build/outputs/apk/gplay/debug/app-gplay-x86_64-debug.apk
|
||||
retention-days: 5
|
||||
overwrite: true
|
||||
if-no-files-found: error
|
||||
|
||||
maestro-cloud:
|
||||
name: Maestro test suite
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ build-apk ]
|
||||
# Allow only one to run at a time, since they use the same environment.
|
||||
# Otherwise, tests running in parallel can break each other.
|
||||
concurrency:
|
||||
group: maestro-test
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Download APK artifact from previous job
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: elementx-apk-maestro
|
||||
- name: Enable KVM group perms
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
- name: Install maestro
|
||||
run: curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||
- name: Run Maestro tests in emulator
|
||||
id: maestro_test
|
||||
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0
|
||||
continue-on-error: true
|
||||
env:
|
||||
MAESTRO_USERNAME: maestroelement
|
||||
MAESTRO_PASSWORD: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}
|
||||
MAESTRO_RECOVERY_KEY: ${{ secrets.MATRIX_MAESTRO_ACCOUNT_RECOVERY_KEY }}
|
||||
MAESTRO_ROOM_NAME: MyRoom
|
||||
MAESTRO_INVITEE1_MXID: "@maestroelement2:matrix.org"
|
||||
MAESTRO_INVITEE2_MXID: "@maestroelement3:matrix.org"
|
||||
MAESTRO_APP_ID: io.element.android.x.debug
|
||||
with:
|
||||
api-level: ${{ env.API_LEVEL }}
|
||||
arch: ${{ env.ARCH }}
|
||||
profile: ${{ env.DEVICE }}
|
||||
target: ${{ env.TARGET }}
|
||||
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: true
|
||||
disk-size: 3G
|
||||
script: |
|
||||
.github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-x86_64-debug.apk
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
~/.maestro/tests/**
|
||||
retention-days: 5
|
||||
overwrite: true
|
||||
if-no-files-found: error
|
||||
- name: Update summary (success)
|
||||
if: steps.maestro_test.outcome == 'success'
|
||||
run: |
|
||||
echo "### Maestro tests worked :rocket:!" >> $GITHUB_STEP_SUMMARY
|
||||
- name: Update summary (failure)
|
||||
if: steps.maestro_test.outcome != 'success'
|
||||
run: |
|
||||
LOG_FILE=$(find ~/.maestro/tests/ -name maestro.log)
|
||||
echo "Log file: $LOG_FILE"
|
||||
LOG_LINES="$(tail -n 30 $LOG_FILE)"
|
||||
echo "### :x: Maestro tests failed...
|
||||
|
||||
\`\`\`
|
||||
$LOG_LINES
|
||||
\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
- name: Fail the workflow in case of error in test
|
||||
if: steps.maestro_test.outcome != 'success'
|
||||
run: |
|
||||
echo "Maestro tests failed. Please check the logs."
|
||||
exit 1
|
||||
66
.github/workflows/nightly.yml
vendored
66
.github/workflows/nightly.yml
vendored
|
|
@ -1,66 +0,0 @@
|
|||
name: Build and release nightly application
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Every nights at 4
|
||||
- cron: "0 4 * * *"
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError -XX:+UseG1GC -Dkotlin.daemon.jvm.options=-Xmx4g
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
name: Build and publish nightly bundle to Firebase
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'element-hq/element-x-android' }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Build and upload Nightly application
|
||||
run: |
|
||||
./gradlew assembleGplayNightly appDistributionUploadGplayNightly $CI_GRADLE_ARG_PROPERTIES
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
ELEMENT_ANDROID_NIGHTLY_KEYID: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYID }}
|
||||
ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_KEYPASSWORD }}
|
||||
ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_STOREPASSWORD }}
|
||||
FIREBASE_TOKEN: ${{ secrets.ELEMENT_ANDROID_NIGHTLY_FIREBASE_TOKEN }}
|
||||
- name: Additionally upload Nightly APK to browserstack for testing
|
||||
continue-on-error: true # don't block anything by this upload failing (for now)
|
||||
run: |
|
||||
curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_PASSWORD" -X POST "https://api-cloud.browserstack.com/app-automate/upload" -F "file=@app/build/outputs/apk/gplay/nightly/app-gplay-universal-nightly.apk" -F "custom_id=element-x-android-nightly"
|
||||
env:
|
||||
BROWSERSTACK_USERNAME: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_USERNAME }}
|
||||
BROWSERSTACK_PASSWORD: ${{ secrets.ELEMENT_ANDROID_BROWSERSTACK_ACCESS_KEY }}
|
||||
98
.github/workflows/nightlyReports.yml
vendored
98
.github/workflows/nightlyReports.yml
vendored
|
|
@ -1,98 +0,0 @@
|
|||
name: Nightly reports
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Every nights at 5
|
||||
- cron: "0 5 * * *"
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
nightlyReports:
|
||||
name: Create kover report artifact and upload sonar result.
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'element-hq/element-x-android' }}
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: ⚙️ Run unit tests, debug and release
|
||||
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: 📸 Run screenshot tests
|
||||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: 📈 Generate kover report and verify coverage
|
||||
run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: ✅ Upload kover report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: kover-results
|
||||
path: |
|
||||
**/build/reports/kover
|
||||
|
||||
- name: 🔊 Publish results to Sonar
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
|
||||
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
|
||||
run: ./gradlew assembleDebug createFullJarDebugTestFixtures :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
# Gradle dependency analysis using https://github.com/autonomousapps/dependency-analysis-android-gradle-plugin
|
||||
dependency-analysis:
|
||||
name: Dependency analysis
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Dependency analysis
|
||||
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload dependency analysis
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: dependency-analysis
|
||||
path: build/reports/dependency-check-report.html
|
||||
30
.github/workflows/post-release.yml
vendored
30
.github/workflows/post-release.yml
vendored
|
|
@ -1,30 +0,0 @@
|
|||
name: Post-release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
post-release:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: github.repository == 'element-hq/element-x-android'
|
||||
|
||||
steps:
|
||||
- name: Trigger pipeline
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.ENTERPRISE_ACTIONS_TOKEN }}
|
||||
script: |
|
||||
const tag = context.ref.replace('refs/tags/', '');
|
||||
const inputs = { git_tag: tag };
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: 'element-hq',
|
||||
repo: 'element-enterprise',
|
||||
workflow_id: 'pipeline-android.yml',
|
||||
ref: 'main',
|
||||
inputs: inputs
|
||||
});
|
||||
82
.github/workflows/pull_request.yml
vendored
82
.github/workflows/pull_request.yml
vendored
|
|
@ -1,82 +0,0 @@
|
|||
name: Pull Request
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [ opened, edited, labeled, unlabeled, synchronize ]
|
||||
workflow_call: # zizmor: ignore[dangerous-triggers]
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN:
|
||||
required: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
prevent-blocked:
|
||||
name: Prevent blocked
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Add notice
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
|
||||
with:
|
||||
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@57e9f42acd78f4d0f496b3be4368fc5f62696662 # v3
|
||||
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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Require to comment and close the PR.
|
||||
pull-requests: write
|
||||
if: >
|
||||
github.event.action == 'opened' &&
|
||||
github.event.pull_request.head.ref == 'develop' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Close pull request
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: "Thanks for opening this pull request, unfortunately we do not accept contributions from the main" +
|
||||
" branch of your fork, please re-open once you switch to an alternative branch for everyone's sanity.",
|
||||
});
|
||||
|
||||
github.rest.pulls.update({
|
||||
pull_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'closed'
|
||||
});
|
||||
369
.github/workflows/quality.yml
vendored
369
.github/workflows/quality.yml
vendored
|
|
@ -1,369 +0,0 @@
|
|||
name: Code Quality Checks
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
checkScript:
|
||||
name: Search for forbidden patterns
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Run code quality check suite
|
||||
run: ./tools/check/check_code_quality.sh
|
||||
|
||||
checkScreenshot:
|
||||
name: Search for invalid screenshot files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.14
|
||||
- name: Search for invalid screenshot files
|
||||
run: ./tools/test/checkInvalidScreenshots.py
|
||||
|
||||
checkDependencies:
|
||||
name: Search for invalid dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.14
|
||||
- name: Search for invalid dependencies
|
||||
run: ./tools/dependencies/checkDependencies.py
|
||||
|
||||
# Code checks
|
||||
konsist:
|
||||
name: Konsist tests
|
||||
runs-on: ubuntu-latest
|
||||
# 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) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Konsist tests
|
||||
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: konsist-report
|
||||
path: |
|
||||
**/build/reports/**/*.*
|
||||
|
||||
compose:
|
||||
name: Compose tests
|
||||
runs-on: ubuntu-latest
|
||||
# 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:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run compose tests
|
||||
run: ./tools/compose/check_stability.sh
|
||||
|
||||
lint:
|
||||
name: Android lint check
|
||||
runs-on: ubuntu-latest
|
||||
# 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) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Build Gplay Debug
|
||||
run: ./gradlew :app:compileGplayDebugKotlin $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Build Fdroid Debug
|
||||
run: ./gradlew :app:compileFdroidDebugKotlin $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Run lint
|
||||
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: linting-report
|
||||
path: |
|
||||
**/build/reports/**/*.*
|
||||
|
||||
detekt:
|
||||
name: Detekt checks
|
||||
runs-on: ubuntu-latest
|
||||
# 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) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Detekt
|
||||
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: detekt-report
|
||||
path: |
|
||||
**/build/reports/**/*.*
|
||||
|
||||
ktlint:
|
||||
name: Ktlint checks
|
||||
runs-on: ubuntu-latest
|
||||
# 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) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Ktlint check
|
||||
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ktlint-report
|
||||
path: |
|
||||
**/build/reports/**/*.*
|
||||
|
||||
docs:
|
||||
name: Doc checks
|
||||
runs-on: ubuntu-latest
|
||||
# Allow all jobs on main and develop. Just one per PR.
|
||||
concurrency:
|
||||
group: ${{ github.ref == 'refs/heads/main' && format('check-docs-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-docs-develop-{0}', github.sha) || format('check-docs-{0}', github.ref) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: Run docs check
|
||||
# This is equivalent to `./gradlew checkDocs`, but we avoid having to install java and gradle
|
||||
run: python3 ./tools/docs/generate_toc.py --verify ./*.md docs/**/*.md
|
||||
|
||||
# Note: to auto fix issues you can use the following command:
|
||||
# shellcheck -f diff <files> | git apply
|
||||
shellcheck:
|
||||
name: Check shell scripts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run shellcheck
|
||||
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0
|
||||
with:
|
||||
severity: warning
|
||||
|
||||
zizmor:
|
||||
name: Run zizmor
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
|
||||
upload_reports:
|
||||
name: Project Check Suite
|
||||
runs-on: ubuntu-latest
|
||||
needs: [konsist, lint, ktlint, detekt]
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Download reports from previous jobs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
- name: Prepare Danger
|
||||
if: always()
|
||||
run: |
|
||||
npm install --save-dev @babel/core
|
||||
npm install --save-dev @babel/plugin-transform-flow-strip-types
|
||||
yarn add danger-plugin-lint-report --dev
|
||||
- name: Danger lint
|
||||
if: always()
|
||||
uses: danger/danger-js@67ed2c1f42fd2fc198cc3c14b43c8f83351f4fe9 # 13.0.5
|
||||
with:
|
||||
args: "--dangerfile ./tools/danger/dangerfile-lint.js"
|
||||
env:
|
||||
DANGER_GITHUB_API_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
|
||||
# Fallback for forks
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
71
.github/workflows/recordScreenshots.yml
vendored
71
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -1,71 +0,0 @@
|
|||
name: Record screenshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [ labeled ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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
|
||||
|
||||
jobs:
|
||||
record:
|
||||
permissions:
|
||||
# Need write permissions on PRs to remove the label "Record-Screenshots"
|
||||
pull-requests: write
|
||||
name: Record screenshots on branch ${{ github.event.pull_request.head.ref || github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Record-Screenshots'
|
||||
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- name: Remove Record-Screenshots label
|
||||
if: github.event.label.name == 'Record-Screenshots'
|
||||
uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1.3.0
|
||||
with:
|
||||
labels: Record-Screenshots
|
||||
- name: ⏬ Checkout with LFS (PR)
|
||||
if: github.event.label.name == 'Record-Screenshots'
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
with:
|
||||
persist-credentials: false
|
||||
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
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
# Add gradle cache, this should speed up the process
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Record screenshots
|
||||
id: record
|
||||
run: ./.github/workflows/scripts/recordScreenshots.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.DANGER_GITHUB_API_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ secrets.GITHUB_REPOSITORY }}
|
||||
GRADLE_ARGS: ${{ env.CI_GRADLE_ARG_PROPERTIES }}
|
||||
146
.github/workflows/release.yml
vendored
146
.github/workflows/release.yml
vendored
|
|
@ -1,146 +0,0 @@
|
|||
name: Create release App Bundle and APKs
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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
|
||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
gplay:
|
||||
name: Create App Bundle (Gplay)
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ format('build-release-main-gplay-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
- name: Create app bundle
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }}
|
||||
ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }}
|
||||
ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }}
|
||||
ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }}
|
||||
ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }}
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload bundle as artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: elementx-app-gplay-bundle-unsigned
|
||||
path: |
|
||||
app/build/outputs/bundle/gplayRelease/app-gplay-release.aab
|
||||
|
||||
enterprise:
|
||||
name: Create App Bundle Enterprise
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
concurrency:
|
||||
group: ${{ format('build-release-main-enterprise-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
run: git submodule update --init --recursive
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
- name: Create Enterprise app bundle
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload bundle as artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: elementx-enterprise-app-gplay-bundle-unsigned
|
||||
path: |
|
||||
app/build/outputs/bundle/gplayRelease/app-gplay-release.aab
|
||||
|
||||
fdroid:
|
||||
name: Create APKs (FDroid)
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ format('build-release-main-fdroid-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
- name: Create APKs
|
||||
env:
|
||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload apks as artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: elementx-app-fdroid-apks-unsigned
|
||||
path: |
|
||||
app/build/outputs/apk/fdroid/release/*.apk
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright (c) 2025 Element Creations Ltd.
|
||||
# Copyright 2024 New Vector Ltd.
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only.
|
||||
# Please see LICENSE in the repository root for full details.
|
||||
#
|
||||
|
||||
COUNT=0
|
||||
mkdir -p /data/local/tmp/recordings;
|
||||
FILENAME=/data/local/tmp/recordings/testRecording$COUNT.mp4
|
||||
while true
|
||||
do
|
||||
COUNT=$((COUNT+1))
|
||||
FILENAME=/data/local/tmp/recordings/testRecording$COUNT.mp4
|
||||
printf "\nRecording video file #%d\n" $COUNT
|
||||
screenrecord --bugreport --bit-rate=16m --size 720x1280 $FILENAME
|
||||
done
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright (c) 2025 Element Creations Ltd.
|
||||
# Copyright 2024 New Vector Ltd.
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only.
|
||||
# Please see LICENSE in the repository root for full details.
|
||||
#
|
||||
|
||||
# First we disable the onboarding flow on Chrome, which is a source of issues
|
||||
# (see https://stackoverflow.com/a/64629745)
|
||||
echo "Disabling Chrome onboarding flow"
|
||||
adb shell am set-debug-app --persistent com.android.chrome
|
||||
adb shell 'echo "chrome --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line'
|
||||
adb shell am start -n com.android.chrome/com.google.android.apps.chrome.Main
|
||||
|
||||
adb install -r $1
|
||||
echo "Starting the screen recording..."
|
||||
adb push .github/workflows/scripts/maestro/local-recording.sh /data/local/tmp/
|
||||
adb shell "chmod +x /data/local/tmp/local-recording.sh"
|
||||
mkdir -p ~/.maestro/tests
|
||||
# Start logcat in the background and save the output to a file, use `org.matrix.rust.sdk` tag since the SDK handles the logging
|
||||
adb logcat 'org.matrix.rust.sdk:D *:S' > ~/.maestro/tests/logcat.txt &
|
||||
adb shell "/data/local/tmp/local-recording.sh & echo \$! > /data/local/tmp/screenrecord_pid.txt" &
|
||||
set +e
|
||||
~/.maestro/bin/maestro test .maestro/allTests.yaml
|
||||
TEST_STATUS=$?
|
||||
echo "Test run completed with status $TEST_STATUS"
|
||||
|
||||
# Stop the screen recording loop
|
||||
SCRIPT_PID=$(adb shell "cat /data/local/tmp/screenrecord_pid.txt")
|
||||
adb shell "kill -2 $SCRIPT_PID"
|
||||
|
||||
# Get the PID of the screen recording process
|
||||
SCREENRECORD_PID=$(adb shell ps | grep screenrecord | awk '{print $2}')
|
||||
# Wait for the screen recording process to exit
|
||||
while [ ! -z $SCREENRECORD_PID ]; do
|
||||
echo "Waiting for screen recording ($SCREENRECORD_PID) to finish..."
|
||||
adb shell "kill -2 $SCREENRECORD_PID"
|
||||
sleep 1
|
||||
SCREENRECORD_PID=$(adb shell ps | grep screenrecord | awk '{print $2}')
|
||||
done
|
||||
|
||||
adb pull /data/local/tmp/recordings/ ~/.maestro/tests/
|
||||
exit $TEST_STATUS
|
||||
77
.github/workflows/scripts/parse_test_failures.py
vendored
77
.github/workflows/scripts/parse_test_failures.py
vendored
|
|
@ -1,77 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
import xml.etree.ElementTree as ET
|
||||
import sys
|
||||
import glob
|
||||
|
||||
screenshot_test_failures = []
|
||||
output = []
|
||||
|
||||
def parse_test_failures(xml_file):
|
||||
"""Parse XML test results and print failures."""
|
||||
tree = ET.parse(xml_file)
|
||||
root = tree.getroot()
|
||||
|
||||
# Find all testcase elements with failure children
|
||||
if root.get("failures", "0") == "0":
|
||||
return
|
||||
|
||||
name = root.get('name', 'Test Suite')
|
||||
is_screenshot_test = name.startswith('ui.Preview')
|
||||
|
||||
if not is_screenshot_test:
|
||||
output.append(f"## {name}")
|
||||
|
||||
for testcase in root.findall('.//testcase'):
|
||||
failure = testcase.find('failure')
|
||||
if failure is not None:
|
||||
# Get testcase attributes
|
||||
classname = testcase.get('classname', '')
|
||||
name = testcase.get('name', '')
|
||||
|
||||
if is_screenshot_test:
|
||||
# For screenshot tests, we want to display the classname as well
|
||||
screenshot_test_failures.append(f"{classname}.{name}")
|
||||
else:
|
||||
# Get failure content (text inside the failure element)
|
||||
failure_message = failure.get('message', '')
|
||||
failure_content = failure.text if failure.text else ''
|
||||
|
||||
# Print in the requested format
|
||||
output.append(f"### {name}")
|
||||
output.append("```")
|
||||
output.append(failure_message)
|
||||
output.append("```")
|
||||
output.append("<details><summary>Stacktrace</summary>")
|
||||
output.append(f"<pre><code>{failure_content}</code></pre>")
|
||||
output.append("</details>")
|
||||
output.append("\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
output.append("Usage: parse_test_failures.py <file>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
file = sys.argv[1]
|
||||
|
||||
if file.endswith('xml'):
|
||||
parse_test_failures(file)
|
||||
else:
|
||||
files = glob.glob("**/build/test-results/*UnitTest/*.xml", root_dir = file, recursive = True)
|
||||
for file in files:
|
||||
parse_test_failures(file)
|
||||
|
||||
if screenshot_test_failures:
|
||||
output.append("## Screenshot Test Failures")
|
||||
output.append("```")
|
||||
for failure in screenshot_test_failures:
|
||||
output.append(failure)
|
||||
output.append("```")
|
||||
|
||||
text_output = '\n'.join(output)
|
||||
# Trim output larger than 1MB to avoid GitHub Action log limits
|
||||
while len(text_output.encode('utf-8')) > 1_040_000:
|
||||
output.pop(-2)
|
||||
output.append("## !!! Truncated output due to size limits. !!!")
|
||||
text_output = '\n'.join(output)
|
||||
|
||||
print(text_output)
|
||||
90
.github/workflows/scripts/recordScreenshots.sh
vendored
90
.github/workflows/scripts/recordScreenshots.sh
vendored
|
|
@ -1,90 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2025 Element Creations Ltd.
|
||||
# Copyright 2023-2024 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.
|
||||
|
||||
set -e
|
||||
|
||||
TOKEN=$GITHUB_TOKEN
|
||||
REPO=$GITHUB_REPOSITORY
|
||||
|
||||
SHORT=t:,r:
|
||||
LONG=token:,repo:
|
||||
OPTS=$(getopt -a -n recordScreenshots --options $SHORT --longoptions $LONG -- "$@")
|
||||
|
||||
eval set -- "$OPTS"
|
||||
while :
|
||||
do
|
||||
case "$1" in
|
||||
-t | --token )
|
||||
TOKEN="$2"
|
||||
shift 2
|
||||
;;
|
||||
-r | --repo )
|
||||
REPO="$2"
|
||||
shift 2
|
||||
;;
|
||||
--)
|
||||
shift;
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "Unexpected option: $1"
|
||||
help
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo Branch used: $BRANCH
|
||||
|
||||
if [[ -z ${TOKEN} ]]; then
|
||||
echo "No token specified, either set the env var GITHUB_TOKEN or use the --token option"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z ${REPO} ]]; then
|
||||
echo "No repo specified, either set the env var GITHUB_REPOSITORY or use the --repo option"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deleting previous screenshots"
|
||||
./gradlew removeOldSnapshots --stacktrace --warn $GRADLE_ARGS
|
||||
|
||||
echo "Record screenshots"
|
||||
./gradlew recordPaparazziDebug --stacktrace $GRADLE_ARGS
|
||||
|
||||
echo "Deleting previous screenshots"
|
||||
./gradlew removeOldScreenshots --stacktrace --warn $GRADLE_ARGS
|
||||
|
||||
echo "Record screenshots (Compound)"
|
||||
./gradlew :libraries:compound:recordRoborazziDebug --stacktrace -PpreDexEnable=false --max-workers 4 --warn $GRADLE_ARGS
|
||||
|
||||
echo "Committing changes"
|
||||
git config http.sslVerify false
|
||||
|
||||
if [[ -z ${INPUT_AUTHOR_NAME} ]]; then
|
||||
git config user.name "ElementBot"
|
||||
else
|
||||
git config --local user.name "${INPUT_AUTHOR_NAME}"
|
||||
fi
|
||||
|
||||
if [[ -z ${INPUT_AUTHOR_EMAIL} ]]; then
|
||||
git config user.email "android@element.io"
|
||||
else
|
||||
git config --local user.name "${INPUT_AUTHOR_EMAIL}"
|
||||
fi
|
||||
git add -A
|
||||
git commit -m "Update screenshots"
|
||||
|
||||
GITHUB_REPO="https://$GITHUB_ACTOR:$TOKEN@github.com/$REPO.git"
|
||||
echo "Pushing changes"
|
||||
if [[ -z ${GITHUB_ACTOR} ]]; then
|
||||
echo "No GITHUB_ACTOR env var"
|
||||
GITHUB_REPO="https://$TOKEN@github.com/$REPO.git"
|
||||
fi
|
||||
git push $GITHUB_REPO "$BRANCH"
|
||||
echo "Done!"
|
||||
63
.github/workflows/sonar.yml
vendored
63
.github/workflows/sonar.yml
vendored
|
|
@ -1,63 +0,0 @@
|
|||
name: Sonar
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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
|
||||
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
|
||||
# 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)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Build debug code and test fixtures
|
||||
run: ./gradlew assembleGplayDebug createFullJarDebugTestFixtures :app:createFullJarGplayDebugTestFixtures $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: 🔊 Publish results to Sonar
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
ORG_GRADLE_PROJECT_SONAR_LOGIN: ${{ secrets.SONAR_TOKEN }}
|
||||
if: ${{ always() && env.SONAR_TOKEN != '' && env.ORG_GRADLE_PROJECT_SONAR_LOGIN != '' }}
|
||||
run: ./gradlew sonar $CI_GRADLE_ARG_PROPERTIES
|
||||
24
.github/workflows/stale-issues.yml
vendored
24
.github/workflows/stale-issues.yml
vendored
|
|
@ -1,24 +0,0 @@
|
|||
name: Close stale issues that are missing info.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
only-labels: "X-Needs-Info"
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 7
|
||||
days-before-pr-stale: -1
|
||||
stale-issue-label: "stale"
|
||||
labels-to-remove-when-unstale: "X-Needs-Info"
|
||||
stale-issue-message: "This issue has been awaiting further information for the past 30 days so will now be marked as stale. Please provide the requested information within the next 7 days to keep it open."
|
||||
close-issue-message: "This issue is being closed due to inactivity after further information was requested."
|
||||
52
.github/workflows/sync-localazy.yml
vendored
52
.github/workflows/sync-localazy.yml
vendored
|
|
@ -1,52 +0,0 @@
|
|||
name: Sync Localazy
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# At 00:00 on every Monday UTC
|
||||
- cron: '0 0 * * 1'
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
sync-localazy:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.14
|
||||
- name: Setup Localazy
|
||||
run: |
|
||||
curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/localazy.gpg] https://maven.localazy.com/repository/apt/ stable main" | sudo tee /etc/apt/sources.list.d/localazy.list
|
||||
sudo apt-get update && sudo apt-get install localazy
|
||||
- name: Run Localazy script
|
||||
run: |
|
||||
./tools/localazy/downloadStrings.sh --all
|
||||
./tools/localazy/importSupportedLocalesFromLocalazy.py
|
||||
./tools/test/generateAllScreenshots.py
|
||||
- name: Create Pull Request for Strings
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.DANGER_GITHUB_API_TOKEN }}
|
||||
commit-message: Sync Strings from Localazy
|
||||
title: Sync Strings
|
||||
body: |
|
||||
- Update Strings from Localazy
|
||||
branch: sync-localazy
|
||||
base: develop
|
||||
labels: PR-i18n
|
||||
40
.github/workflows/sync-sas-strings.yml
vendored
40
.github/workflows/sync-sas-strings.yml
vendored
|
|
@ -1,40 +0,0 @@
|
|||
name: Sync SAS strings
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# At 00:00 on every Monday UTC
|
||||
- cron: '0 0 * * 1'
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
sync-sas-strings:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
# No concurrency required, runs every time on a schedule.
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: 3.14
|
||||
- name: Install Prerequisite dependencies
|
||||
run: |
|
||||
pip install requests
|
||||
- name: Run SAS String script
|
||||
run: ./tools/sas/import_sas_strings.py
|
||||
- name: Create Pull Request for SAS Strings
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
commit-message: Sync SAS Strings
|
||||
title: Sync SAS Strings
|
||||
body: |
|
||||
- Update SAS Strings from matrix-doc.
|
||||
branch: sync-sas-strings
|
||||
base: develop
|
||||
labels: PR-Misc
|
||||
|
||||
|
||||
116
.github/workflows/tests.yml
vendored
116
.github/workflows/tests.yml
vendored
|
|
@ -1,116 +0,0 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
merge_group:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
# 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 --no-daemon -Dsonar.gradle.skipCompile=true --no-configuration-cache
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
name: Runs unit tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# 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:
|
||||
- name: Free Disk Space (Ubuntu)
|
||||
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be
|
||||
with:
|
||||
# This might remove tools that are actually needed, if set to "true" but frees about 6 GB
|
||||
tool-cache: true
|
||||
# All of these default to true, but we should only need the 'android' one (and maybe swap-storage?)
|
||||
android: false
|
||||
dotnet: true
|
||||
haskell: true
|
||||
# This takes way too long to run (~2 minutes) and it saves only ~5.5GB
|
||||
large-packages: false
|
||||
docker-images: true
|
||||
swap-storage: false
|
||||
|
||||
# Increase swapfile size to prevent screenshot tests getting terminated
|
||||
# https://github.com/actions/runner-images/discussions/7188#discussioncomment-6750749
|
||||
- name: 💽 Increase swapfile size
|
||||
run: |
|
||||
sudo swapoff -a
|
||||
sudo fallocate -l 8G /mnt/swapfile
|
||||
sudo chmod 600 /mnt/swapfile
|
||||
sudo mkswap /mnt/swapfile
|
||||
sudo swapon /mnt/swapfile
|
||||
sudo swapon --show
|
||||
- name: ⏬ Checkout with LFS
|
||||
uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
with:
|
||||
# Ensure we are building the branch and not the branch after being merged on develop
|
||||
# https://github.com/actions/checkout/issues/881
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }}
|
||||
- name: Add SSH private keys for submodule repositories
|
||||
uses: webfactory/ssh-agent@e83874834305fe9a4a2997156cb26c5de65a8555 # v0.10.0
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.ELEMENT_ENTERPRISE_DEPLOY_KEY }}
|
||||
- name: Clone submodules
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'element-hq/element-x-android' }}
|
||||
run: git submodule update --init --recursive
|
||||
- name: ☕️ Use JDK 21
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||
java-version: '21'
|
||||
- name: Configure gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
- name: ⚙️ Check coverage for debug variant (includes unit & screenshot tests)
|
||||
run: ./gradlew testDebugUnitTest :tests:uitests:verifyPaparazziDebug :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: 🚫 Upload kover failed coverage reports
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: kover-error-report
|
||||
path: |
|
||||
app/build/reports/kover
|
||||
|
||||
- name: ✅ Upload kover report (disabled)
|
||||
if: always()
|
||||
run: echo "This is now done only once a day, see nightlyReports.yml"
|
||||
|
||||
- name: 🚫 Upload test results on error
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: tests-and-screenshot-tests-results
|
||||
path: |
|
||||
**/build/paparazzi/failures/
|
||||
**/build/roborazzi/failures/
|
||||
**/build/reports/tests/*UnitTest/
|
||||
|
||||
- name: 🚫 Modify summary on error
|
||||
if: failure()
|
||||
run: |
|
||||
echo """## Tests failed!
|
||||
|
||||
""" >> $GITHUB_STEP_SUMMARY
|
||||
python3 .github/workflows/scripts/parse_test_failures.py . >> $GITHUB_STEP_SUMMARY
|
||||
echo "---" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# https://github.com/codecov/codecov-action
|
||||
- name: ☂️ Upload coverage reports to codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: build/reports/kover/reportMerged.xml
|
||||
verbose: true
|
||||
16
.github/workflows/triage-incoming.yml
vendored
16
.github/workflows/triage-incoming.yml
vendored
|
|
@ -1,16 +0,0 @@
|
|||
name: Move new issues onto issue triage board v2
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [ opened ]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
triage-new-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/91
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
87
.github/workflows/triage-labelled.yml
vendored
87
.github/workflows/triage-labelled.yml
vendored
|
|
@ -1,87 +0,0 @@
|
|||
name: Move labelled issues to correct boards and columns
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
move_element_x_issues:
|
||||
name: ElementX issues to ElementX project board
|
||||
runs-on: ubuntu-latest
|
||||
# Skip in forks
|
||||
if: >
|
||||
github.repository == 'element-hq/element-x-android'
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/43
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
move_needs_info:
|
||||
name: Move triaged needs info issues on board
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
id: addItem
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/91
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
labeled: X-Needs-Info
|
||||
- name: Print itemId
|
||||
run: echo ${STEPS_ADDITEM_OUTPUTS_ITEMID}
|
||||
env:
|
||||
STEPS_ADDITEM_OUTPUTS_ITEMID: ${{ steps.addItem.outputs.itemId }}
|
||||
- uses: kalgurn/update-project-item-status@31e54df46a2cdaef4f85c31ac839fbcd2fd7c3a2 # 0.0.3
|
||||
if: ${{ steps.addItem.outputs.itemId }}
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/91
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
item-id: ${{ steps.addItem.outputs.itemId }}
|
||||
status: "Needs info"
|
||||
|
||||
ex_plorers:
|
||||
name: Add labelled issues to X-Plorer project
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'Team: Element X Feature')
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/73
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
verticals_feature:
|
||||
name: Add labelled issues to Verticals Feature project
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'Team: Verticals Feature')
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/57
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
qa:
|
||||
name: Add labelled issues to QA project
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'Team: QA') ||
|
||||
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/69
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
|
||||
signoff:
|
||||
name: Add labelled issues to signoff project
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
contains(github.event.issue.labels.*.name, 'X-Needs-Signoff')
|
||||
steps:
|
||||
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
|
||||
with:
|
||||
project-url: https://github.com/orgs/element-hq/projects/89
|
||||
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
15
.github/workflows/validate-lfs.yml
vendored
15
.github/workflows/validate-lfs.yml
vendored
|
|
@ -1,15 +0,0 @@
|
|||
name: Validate Git LFS
|
||||
|
||||
on: [pull_request, merge_group]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Validate
|
||||
steps:
|
||||
- uses: nschloe/action-cached-lfs-checkout@385a8ecc719e50b8c71af6ab01a624b486b7c3bc # v1.2.5
|
||||
|
||||
- run: |
|
||||
./tools/git/validate_lfs.sh
|
||||
38
.gitleaks.toml
Normal file
38
.gitleaks.toml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# gitleaks config — element-x-ada
|
||||
#
|
||||
# Element X is a Matrix client fork with Cardano ADA integration.
|
||||
# Patterns flagged are all public-by-design or doc/test fixtures:
|
||||
# - PostHog apiKey: client-side analytics token, public on every PostHog-
|
||||
# integrated mobile app. Identifies the project, doesn't grant write.
|
||||
# - MapTiler API_KEY: client-side maps token, ships in every release
|
||||
# - google-services.json: Firebase config — Google explicitly documents
|
||||
# this as public-by-design (all real auth goes through FirebaseAuth)
|
||||
# - Segment readKey: client-side write key
|
||||
# - user_signing_key in KDoc comments: example values in doc-strings
|
||||
# - docs/ + *Test.kt files: scratch + test fixtures, never live credentials
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Public client keys (PostHog, MapTiler, Firebase, Segment) + docs + test fixtures"
|
||||
paths = [
|
||||
'''docs/.*''',
|
||||
'''.*/google-services\.json''',
|
||||
'''.*Test\.kt''',
|
||||
'''localazy\.json''',
|
||||
'''tools/localazy/.*''',
|
||||
]
|
||||
regexTarget = "line"
|
||||
regexes = [
|
||||
# PostHog client keys — match any variable name ending in apiKey
|
||||
'''[a-zA-Z]*[Aa]piKey\s*=\s*"phc_[A-Za-z0-9_-]{20,}"''',
|
||||
# MapTiler / similar public client keys named API_KEY constant
|
||||
'''const\s+val\s+API_KEY\s*=\s*"''',
|
||||
# Segment write keys (Kotlin style)
|
||||
'''readKey\s*=\s*"''',
|
||||
# Localazy / Segment readKey (JSON style)
|
||||
'''"readKey"\s*:\s*"''',
|
||||
# Matrix protocol KDoc examples (* prefix is the KDoc comment shape)
|
||||
'''^\s*\*\s*"user_signing_key"\s*:\s*"''',
|
||||
]
|
||||
221
BLOCKERS.md
Normal file
221
BLOCKERS.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# BLOCKERS.md - Phase 1 Implementation Status
|
||||
|
||||
## Task 1: Module Scaffolding ✅ COMPLETE
|
||||
|
||||
### Completed
|
||||
- ✅ Module structure created (api/impl/test)
|
||||
- ✅ Metro DI setup following Element X patterns
|
||||
- ✅ WalletEntryPoint and WalletState APIs defined
|
||||
- ✅ PaymentFlowNode placeholder with Appyx navigation
|
||||
- ✅ FakeWalletEntryPoint for testing
|
||||
- ✅ Cardano client library dependencies added
|
||||
- ✅ ProGuard rules configured
|
||||
- ✅ Basic unit tests added
|
||||
- ✅ Pushed to Gitea phase1-dev branch
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Key Generation + Storage ✅ COMPLETE
|
||||
|
||||
### Completed
|
||||
- ✅ **CardanoNetworkConfig.kt** - Single object for testnet/mainnet config swap
|
||||
- Currently configured for TESTNET (preprod)
|
||||
- Change `NETWORK` to `CardanoNetwork.MAINNET` for production
|
||||
- All derived values (Koios URL, explorer URL, address prefix) auto-switch
|
||||
|
||||
- ✅ **CardanoKeyStorage** (interface + implementation)
|
||||
- Per-session wallet isolation (key alias: `cardano_wallet_{sessionId}`)
|
||||
- 24-word BIP-39 mnemonic generation using cardano-client-lib
|
||||
- AES-GCM-256 encryption with Android Keystore-backed key
|
||||
- `setUserAuthenticationRequired(true)` - biometric/PIN for every operation
|
||||
- `setUserAuthenticationValidityDurationSeconds(-1)` - no grace period
|
||||
- `setInvalidatedByBiometricEnrollment(true)` - invalidate on biometric change
|
||||
- Methods: `generateWallet`, `importWallet`, `getMnemonic`, `getBaseAddress`, `getStakeAddress`, `deleteWallet`
|
||||
|
||||
- ✅ **CardanoWalletManager** (interface + implementation)
|
||||
- Key derivation using CIP-1852 via cardano-client-lib's Account class
|
||||
- Path `m/1852'/1815'/0'/0/0` for external receiving address
|
||||
- Path `m/1852'/1815'/0'/2/0` for staking key
|
||||
- Shelley base address generation (payment + staking key hash)
|
||||
- Uses CardanoNetworkConfig for network selection
|
||||
- Exposes: `getAddress(sessionId)`, `getStakeAddress(sessionId)`, `getSpendingKey(sessionId)`
|
||||
|
||||
- ✅ **SeedPhraseManager** (interface + implementation)
|
||||
- 24-word mnemonic generation (256-bit entropy)
|
||||
- Support for 12/15/18/21/24 word counts
|
||||
- BIP-39 validation (checksum + wordlist)
|
||||
- Word suggestions for autocomplete
|
||||
- Normalization (whitespace, case)
|
||||
- ⚠️ UI must apply `FLAG_SECURE` when displaying seed phrases (documented)
|
||||
|
||||
- ✅ **FakeCardanoKeyStorage** for testing
|
||||
- ✅ Unit tests for SeedPhraseManager, CardanoNetworkConfig, CardanoWalletManager
|
||||
|
||||
### Decisions Made (per instructions)
|
||||
- Wallet scope: **PER SESSION** (each Matrix account has its own wallet)
|
||||
- Biometric change: **INVALIDATE** key + require wallet re-import/creation
|
||||
- Network: **TESTNET** (preprod) - single config constant for easy mainnet swap
|
||||
|
||||
### Not Verified (No Android SDK in build environment)
|
||||
- ⚠️ Compilation with `./gradlew :features:wallet:impl:assemble`
|
||||
- ⚠️ Unit tests with `./gradlew :features:wallet:impl:test`
|
||||
- ⚠️ ktlint compliance
|
||||
- ⚠️ Actual Android Keystore behavior (requires device/emulator)
|
||||
- ⚠️ Biometric prompt integration (requires Activity context)
|
||||
|
||||
### Security Notes
|
||||
1. **Mnemonic never stored in plaintext** - Always encrypted with Keystore key
|
||||
2. **Key material cleared after use** - `ByteArray.fill(0)` called where possible
|
||||
3. **Per-session isolation** - Different Matrix accounts cannot access each other's wallets
|
||||
4. **Biometric invalidation** - If user adds/removes fingerprints, wallet key becomes invalid
|
||||
5. **No screenshots** - UI must apply FLAG_SECURE when showing seed phrase
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Koios Client ✅ COMPLETE
|
||||
|
||||
### Completed
|
||||
- ✅ **CardanoClient.kt** interface in `api/` module:
|
||||
- `getBalance(address: String): Result<Long>` — balance in lovelace
|
||||
- `getUtxos(address: String): Result<List<Utxo>>` — unspent outputs
|
||||
- `submitTx(signedTxCbor: String): Result<String>` — returns tx hash
|
||||
- `getTxStatus(txHash: String): Result<TxStatus>` — PENDING/CONFIRMED/FAILED
|
||||
|
||||
- ✅ **Data models** in `api/`:
|
||||
- `Utxo.kt` — txHash, outputIndex, amount, address
|
||||
- `TxStatus.kt` — enum PENDING/CONFIRMED/FAILED
|
||||
- `CardanoException.kt` — typed exceptions (NetworkException, RateLimitException, InvalidAddressException, TransactionNotFoundException, SubmissionFailedException, InsufficientFundsException, ApiException)
|
||||
|
||||
- ✅ **KoiosCardanoClient.kt** implementation:
|
||||
- Uses `BackendFactory.getKoiosBackendService()` from cardano-client-lib
|
||||
- Testnet URL: `https://preprod.koios.rest/api/v1` (via CardanoNetworkConfig)
|
||||
- Mainnet URL: `https://api.koios.rest/api/v1` (via CardanoNetworkConfig)
|
||||
- 3 retries with exponential backoff (1s → 2s → 4s, max 10s)
|
||||
- Basic rate limiting (100ms min between requests for Koios 100 req/10s limit)
|
||||
- DI: `@ContributesBinding(SessionScope::class)`
|
||||
- Error parsing: 429 → RateLimitException, 5xx → NetworkException, etc.
|
||||
|
||||
- ✅ **FakeCardanoClient.kt** for testing:
|
||||
- Configurable balances, UTxOs, transaction statuses
|
||||
- Error simulation (network errors, rate limits, submit failures)
|
||||
- Transaction lifecycle simulation (pending → confirmed → failed)
|
||||
- Call counters for test verification
|
||||
- Helper: `setupWallet(address, balance)` creates realistic UTxO set
|
||||
|
||||
- ✅ **KoiosCardanoClientTest.kt** — 15+ unit tests:
|
||||
- getBalance success, unknown address, network error, rate limit
|
||||
- getUtxos success, empty result
|
||||
- submitTx success, failure
|
||||
- getTxStatus pending, confirmed, failed
|
||||
- reset/state management
|
||||
|
||||
- ✅ **CardanoWalletManager updated** to use CardanoClient:
|
||||
- `refreshBalance()` now fetches real balance via Koios
|
||||
- Updates WalletState with lovelace + formatted ADA string
|
||||
|
||||
### Design Notes
|
||||
- **No API key required** — Koios public API is free
|
||||
- **Network config centralized** — Change `CardanoNetworkConfig.NETWORK` to swap testnet/mainnet
|
||||
- **Hex CBOR for submitTx** — Accepts hex-encoded signed transaction bytes
|
||||
- **UTxO pagination** — Limited to first 100 UTxOs (sufficient for typical wallets)
|
||||
|
||||
### Potential Issues
|
||||
- ⚠️ `getTxStatus` returns PENDING for unknown hashes (could be never-submitted or truly pending)
|
||||
- ⚠️ Koios rate limit (100 req/10s) may need adjustment for heavy usage patterns
|
||||
- ⚠️ No getProtocolParameters yet (needed for Task 4 fee calculation)
|
||||
|
||||
---
|
||||
|
||||
## Task 4-6: See PHASE1-PLAN.md
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Timeline Payment Card ✅ COMPLETE
|
||||
|
||||
### Completed
|
||||
- ✅ **PaymentCardStatus.kt** — Enum for PENDING/CONFIRMED/FAILED states
|
||||
- ✅ **TimelineItemPaymentContent.kt** — Data class implementing TimelineItemEventContent
|
||||
- amountLovelace, addresses, txHash, status, network, isSentByMe
|
||||
- Computed properties: amountAda, isTestnet, truncatedTxHash, explorerUrl
|
||||
- Companion formatAda() helper
|
||||
- ✅ **TimelineItemPaymentView.kt** — Compose UI for payment card
|
||||
- Cardano icon (₳ symbol)
|
||||
- Amount in ADA (formatted from lovelace)
|
||||
- Status chip with spinner (pending), checkmark (confirmed), X (failed)
|
||||
- Testnet badge when applicable
|
||||
- Truncated tx hash (tappable → CardanoScan)
|
||||
- View on explorer link for confirmed transactions
|
||||
- @PreviewsDayNight with multiple preview states
|
||||
- ✅ **TimelineItemPaymentContentTest.kt** — Unit tests for content model
|
||||
- ✅ **Integration with TimelineItemEventContentView.kt**
|
||||
|
||||
### Design Notes
|
||||
- Payment cards use different colors for sent (primary) vs received (surface)
|
||||
- Explorer URLs: preprod.cardanoscan.io for testnet, cardanoscan.io for mainnet
|
||||
- Tx hash truncated to first 8 + last 8 chars for display
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Raw Event Handling ✅ COMPLETE (UPGRADED)
|
||||
|
||||
### ✅ RESOLVED: SDK Raw Event API
|
||||
**Previous blocker:** Matrix Rust SDK did not expose raw event sending or raw JSON access.
|
||||
|
||||
**Resolution:** The SDK (version 26.03.24) now provides:
|
||||
- `Timeline.sendRaw(eventType: String, content: String)` — Sends custom event types
|
||||
- `MsgLikeKind.Other` with `eventType` field — Receives custom events
|
||||
- `TimelineItemDebugInfo.originalJson` — Access to raw event JSON via debug info provider
|
||||
|
||||
**Implementation updated to use proper raw events instead of text markers.**
|
||||
|
||||
### Completed
|
||||
- ✅ **PaymentEventSender.kt** — Interface for sending payment events
|
||||
- ✅ **DefaultPaymentEventSender.kt** — Implementation using raw events
|
||||
- Uses `timeline.sendRaw(eventType, content)` to send custom events
|
||||
- Event type: `co.sulkta.payment.request` (reverse-domain format)
|
||||
- Status updates: `co.sulkta.payment.status`
|
||||
- No text marker hack — proper Matrix custom events
|
||||
- ✅ **TimelineItemContentPaymentFactory.kt** — Parser for payment events
|
||||
- `isPaymentEventType(eventType)` — Checks for payment event type
|
||||
- `isStatusUpdateEventType(eventType)` — Checks for status update type
|
||||
- `createFromRaw(json, isSentByMe)` — Parses raw JSON from custom events
|
||||
- Supports both camelCase and snake_case field names
|
||||
- Graceful error handling — returns null on malformed JSON
|
||||
- ✅ **TimelineEventContentMapper.kt** — Maps `MsgLikeKind.Other` to `CustomEventContent`
|
||||
- ✅ **TimelineItemContentFactory.kt** — Handles `CustomEventContent` for payments
|
||||
- Gets raw JSON via `timelineItemDebugInfoProvider().originalJson`
|
||||
- Delegates to paymentFactory for payment event types
|
||||
- ✅ **CustomEventContent.kt** — New EventContent type for custom events
|
||||
- ✅ **Timeline.sendRaw()** — Added to Timeline interface and RustTimeline implementation
|
||||
- ✅ **FakePaymentEventSender.kt** — Test fake
|
||||
- ✅ **TimelineItemContentPaymentFactoryTest.kt** — Updated unit tests
|
||||
|
||||
### m.replace Status Updates
|
||||
**Decision:** Status updates are sent as separate events of type `co.sulkta.payment.status`.
|
||||
|
||||
**Future improvement:** When SDK exposes event relations, refactor to use m.replace for cleaner status update thread.
|
||||
|
||||
### Benefits of Raw Event Approach
|
||||
- ✅ Proper Matrix protocol compliance (custom event types, not hacked text)
|
||||
- ✅ Non-wallet clients see "Unknown event" instead of JSON-in-text
|
||||
- ✅ Clean separation of payment events from regular messages
|
||||
- ✅ Events won't be indexed by message search
|
||||
- ✅ No message length limits concern
|
||||
|
||||
---
|
||||
|
||||
## Known Issues
|
||||
|
||||
### Issue 1: Biometric Prompt Activity Context
|
||||
The `CardanoKeyStorageImpl` uses `setUserAuthenticationRequired(true)` which will cause `UserNotAuthenticatedException` when accessing the key. The biometric prompt UI must be triggered from an Activity/Fragment context before calling `getMnemonic()`, `getSpendingKey()`, etc.
|
||||
|
||||
**Solution:** Task 6 (Payment Flow UI) must call BiometricPrompt before invoking storage operations.
|
||||
|
||||
### Issue 2: KeyPermanentlyInvalidatedException
|
||||
If user changes biometric enrollment, the Keystore key is invalidated. Current behavior: throws exception, user must delete and recreate wallet.
|
||||
|
||||
**Enhancement (future):** Show user-friendly message explaining why wallet became invalid and offer to re-import.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: 2026-03-27 - Task 2 complete*
|
||||
34
PHASE1-STATUS.md
Normal file
34
PHASE1-STATUS.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Phase 1 Status — COMPLETE ✅
|
||||
|
||||
## Verification Date
|
||||
2026-03-28
|
||||
|
||||
## What Was Verified
|
||||
- APK: `app-gplay-x86_64-debug.apk` built from `phase1-dev` branch
|
||||
- Installed on Android emulator `budtmo/docker-android:emulator_14.0` (emulator-5554)
|
||||
- Signed in as `@testbot-elementx:sulkta.com` via OIDC (MAS at mas.sulkta.com)
|
||||
- Opened DM room with `@cobb:sulkta.com`
|
||||
- Typed `/pay` in message composer
|
||||
|
||||
## Result
|
||||
✅ Slash command autocomplete appeared showing:
|
||||
- Command: `/pay`
|
||||
- Description: "Send ADA to someone"
|
||||
|
||||
## Phase 1 Bar (Option A) — All Conditions Met
|
||||
- [x] App launches without crash
|
||||
- [x] `/pay` appears in slash command autocomplete
|
||||
- [x] Payment screens navigable (wired in DI graph)
|
||||
- [x] No live testnet transaction required
|
||||
|
||||
## Build Info
|
||||
- Gradle task: `:app:assembleGplayDebug`
|
||||
- Branch: `phase1-dev`
|
||||
- Final commit: `ad89eddfea`
|
||||
- Build image: `mingc/android-build-box:latest` (Java 21)
|
||||
|
||||
## Key Fixes Applied
|
||||
1. Metro DI scope mismatch: CardanoWalletManager removed CardanoClient dep (AppScope vs SessionScope)
|
||||
2. WalletState constructor: all required fields populated
|
||||
3. Packaging conflict: moshi-kotlin-codegen/lombok META-INF pickFirst
|
||||
4. Build flavor: assembleGplayDebug (not fdroid, not plain assembleDebug)
|
||||
78
SYNC.md
Normal file
78
SYNC.md
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# Repo topology + upstream sync procedure
|
||||
|
||||
This repo is a fork of [`element-hq/element-x-android`](https://github.com/element-hq/element-x-android)
|
||||
with a native Cardano wallet module added. The history is structured so that
|
||||
staying current with upstream — and one day proposing our additions back —
|
||||
stays possible.
|
||||
|
||||
## Branches
|
||||
|
||||
| Branch | Role |
|
||||
|--------|------|
|
||||
| `main` | Tracks the upstream commit we are currently based on. Fast-forwarded to `upstream/develop` when we deliberately pull in changes. Nothing coop-specific lives here. |
|
||||
| `wallet` | `main` + all our wallet work. This is what we build APKs from. Linear history on top of `main`; rebased whenever `main` moves. |
|
||||
| `archive/project-docs` | Frozen snapshot of the planning docs and screenshots that lived on the original orphan `main` branch. Not part of the active graph. |
|
||||
|
||||
When we ever want a clean "everything we'd propose upstream" branch, we cherry-pick
|
||||
the wallet commits off `wallet` onto a fresh branch rooted at `main`. Because every
|
||||
current commit on `wallet` is wallet-module work, that split is simple.
|
||||
|
||||
## Remotes
|
||||
|
||||
`origin` → this Gitea repo (LAN, via the Rackham SSH tunnel when working remotely).
|
||||
|
||||
Add upstream on any local clone:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/element-hq/element-x-android.git
|
||||
git fetch upstream
|
||||
```
|
||||
|
||||
## Sync with upstream
|
||||
|
||||
When you want to pick up the latest from `element-hq/element-x-android`:
|
||||
|
||||
```bash
|
||||
# 1. Get the latest from upstream
|
||||
git fetch upstream
|
||||
|
||||
# 2. Fast-forward main to upstream/develop
|
||||
git checkout main
|
||||
git merge --ff-only upstream/develop
|
||||
git push origin main
|
||||
|
||||
# 3. Rebase wallet onto the new main
|
||||
git checkout wallet
|
||||
git rebase main
|
||||
# → resolve conflicts, one commit at a time
|
||||
# → conflict surface is small but real: our integration touches
|
||||
# libraries/matrix/{api,impl}, libraries/textcomposer/impl,
|
||||
# libraries/eventformatter/impl, libraries/mediaviewer/impl
|
||||
|
||||
# 4. Build + test the APK before force-pushing
|
||||
./gradlew :app:assembleFdroidDebug # or mainnet variant
|
||||
|
||||
# 5. Push the rebased wallet branch (force-with-lease, not plain force)
|
||||
git push --force-with-lease origin wallet
|
||||
```
|
||||
|
||||
If the rebase gets ugly, abort and try merging instead:
|
||||
|
||||
```bash
|
||||
git rebase --abort
|
||||
git merge upstream/develop
|
||||
# resolves in one shot, one merge commit, less clean history
|
||||
```
|
||||
|
||||
## Why not a Gitea mirror?
|
||||
|
||||
Gitea only lets you configure a pull-mirror at repo-creation time, and mirroring
|
||||
a whole repo also means we can't commit to it. We want to keep our own commits,
|
||||
so upstream stays as a git remote you fetch from manually.
|
||||
|
||||
## License
|
||||
|
||||
Upstream is **AGPL-3.0**. Every binary we hand out must be accompanied by the
|
||||
corresponding source under the same license. Keeping this Gitea repo accessible
|
||||
to recipients of the APK satisfies that. Don't ship binaries without also making
|
||||
the source reachable.
|
||||
|
|
@ -208,6 +208,7 @@ android {
|
|||
packaging {
|
||||
resources.pickFirsts += setOf(
|
||||
"META-INF/versions/9/OSGI-INF/MANIFEST.MF",
|
||||
"META-INF/gradle/incremental.annotation.processors",
|
||||
)
|
||||
|
||||
jniLibs {
|
||||
|
|
@ -315,6 +316,11 @@ licensee {
|
|||
allowUrl("https://asm.ow2.io/license.html")
|
||||
allowUrl("https://www.gnu.org/licenses/agpl-3.0.txt")
|
||||
allowUrl("https://github.com/mhssn95/compose-color-picker/blob/main/LICENSE")
|
||||
allowUrl("https://opensource.org/licenses/mit-license.php")
|
||||
allowUrl("https://github.com/javaee/javax.annotation/blob/master/LICENSE")
|
||||
allowUrl("https://www.bouncycastle.org/licence.html")
|
||||
allowUrl("https://projectlombok.org/LICENSE")
|
||||
allow("CC0-1.0")
|
||||
ignoreDependencies("com.github.matrix-org", "matrix-analytics-events")
|
||||
// Ignore dependency that are not third-party licenses to us.
|
||||
ignoreDependencies(groupId = "io.element.android")
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ dependencies {
|
|||
implementation(projects.libraries.uiUtils)
|
||||
implementation(projects.libraries.testtags)
|
||||
implementation(projects.features.networkmonitor.api)
|
||||
implementation(projects.features.wallet.impl)
|
||||
implementation(projects.services.analytics.compose)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
implementation(projects.services.toolbox.api)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.duration
|
||||
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.features.poll.api.create.CreatePollMode
|
||||
import io.element.android.features.wallet.api.WalletEntryPoint
|
||||
import io.element.android.features.wallet.impl.panel.WalletPanelNode
|
||||
import io.element.android.features.wallet.impl.setup.WalletSetupNode
|
||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.callback
|
||||
|
|
@ -106,6 +109,7 @@ class MessagesFlowNode(
|
|||
private val shareLocationEntryPoint: ShareLocationEntryPoint,
|
||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
private val walletEntryPoint: WalletEntryPoint,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
private val forwardEntryPoint: ForwardEntryPoint,
|
||||
|
|
@ -181,6 +185,20 @@ class MessagesFlowNode(
|
|||
@Parcelize
|
||||
data class Thread(val threadRootId: ThreadId, val focusedEventId: EventId?) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object WalletPanel : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object WalletSetup : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class PaymentFlow(
|
||||
val roomId: RoomId,
|
||||
val recipientUserId: UserId?,
|
||||
val recipientAddress: String?,
|
||||
val amountLovelace: Long?,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object ThreadsList : NavTarget
|
||||
}
|
||||
|
|
@ -298,6 +316,19 @@ class MessagesFlowNode(
|
|||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||
}
|
||||
|
||||
override fun navigateToWallet() {
|
||||
backstack.push(NavTarget.WalletPanel)
|
||||
}
|
||||
|
||||
override fun navigateToPaymentFlow(
|
||||
roomId: RoomId,
|
||||
recipientUserId: UserId?,
|
||||
recipientAddress: String?,
|
||||
amountLovelace: Long?,
|
||||
) {
|
||||
backstack.push(NavTarget.PaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace))
|
||||
}
|
||||
|
||||
override fun navigateToThreadsList() {
|
||||
backstack.push(NavTarget.ThreadsList)
|
||||
}
|
||||
|
|
@ -519,12 +550,77 @@ class MessagesFlowNode(
|
|||
backstack.push(NavTarget.Thread(threadRootId, focusedEventId))
|
||||
}
|
||||
|
||||
override fun navigateToPaymentFlow(
|
||||
roomId: RoomId,
|
||||
recipientUserId: UserId?,
|
||||
recipientAddress: String?,
|
||||
amountLovelace: Long?,
|
||||
) {
|
||||
backstack.push(NavTarget.PaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace))
|
||||
}
|
||||
|
||||
override fun navigateToDeveloperSettings() {
|
||||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
}
|
||||
createNode<ThreadedMessagesNode>(buildContext, listOf(inputs, callback))
|
||||
}
|
||||
is NavTarget.WalletPanel -> {
|
||||
val walletPanelCallback = object : WalletPanelNode.Callback {
|
||||
override fun onClose() {
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onSendAda() {
|
||||
backstack.pop()
|
||||
backstack.push(NavTarget.PaymentFlow(room.roomId, null, null, null))
|
||||
}
|
||||
|
||||
override fun onSetupWallet() {
|
||||
backstack.push(NavTarget.WalletSetup)
|
||||
}
|
||||
}
|
||||
createNode<WalletPanelNode>(buildContext, listOf(walletPanelCallback))
|
||||
}
|
||||
is NavTarget.WalletSetup -> {
|
||||
val setupCallback = object : WalletSetupNode.Callback {
|
||||
override fun onSetupComplete() {
|
||||
// Pop setup, stay on wallet panel which will now show the wallet
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onBack() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
createNode<WalletSetupNode>(buildContext, listOf(setupCallback))
|
||||
}
|
||||
is NavTarget.PaymentFlow -> {
|
||||
val walletCallback = object : WalletEntryPoint.Callback {
|
||||
override fun onPaymentSent(txHash: String) {
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onPaymentCancelled() {
|
||||
backstack.pop()
|
||||
}
|
||||
|
||||
override fun onOpenWalletSettings() {
|
||||
backstack.pop()
|
||||
backstack.push(NavTarget.WalletPanel)
|
||||
}
|
||||
}
|
||||
walletEntryPoint.paymentFlowBuilder(
|
||||
parentNode = this,
|
||||
buildContext = buildContext,
|
||||
callback = walletCallback,
|
||||
)
|
||||
.setRoomId(navTarget.roomId)
|
||||
.setRecipientUserId(navTarget.recipientUserId)
|
||||
.setRecipientAddress(navTarget.recipientAddress)
|
||||
.setAmount(navTarget.amountLovelace?.toString())
|
||||
.build()
|
||||
}
|
||||
NavTarget.ThreadsList -> {
|
||||
val callback = object : ThreadsListNode.Callback {
|
||||
override fun openThread(threadId: ThreadId) {
|
||||
|
|
|
|||
|
|
@ -26,5 +26,21 @@ interface MessagesNavigator {
|
|||
fun navigateToMember(userId: UserId)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
|
||||
/**
|
||||
* Navigate to the payment flow for /pay slash command.
|
||||
*
|
||||
* @param roomId The current room ID
|
||||
* @param recipientUserId Optional Matrix user ID recipient
|
||||
* @param recipientAddress Optional Cardano address recipient
|
||||
* @param amountLovelace Optional amount in lovelace
|
||||
*/
|
||||
fun navigateToPaymentFlow(
|
||||
roomId: RoomId,
|
||||
recipientUserId: UserId? = null,
|
||||
recipientAddress: String? = null,
|
||||
amountLovelace: Long? = null,
|
||||
)
|
||||
|
||||
fun close()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,8 +131,9 @@ class MessagesNode(
|
|||
fun navigateToPinnedMessagesList()
|
||||
fun navigateToKnockRequestsList()
|
||||
fun navigateToDeveloperSettings()
|
||||
|
||||
fun navigateToThreadsList()
|
||||
fun navigateToWallet()
|
||||
fun navigateToPaymentFlow(roomId: RoomId, recipientUserId: UserId?, recipientAddress: String?, amountLovelace: Long?)
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
|
|
@ -237,6 +238,15 @@ class MessagesNode(
|
|||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
|
||||
override fun navigateToPaymentFlow(
|
||||
roomId: RoomId,
|
||||
recipientUserId: UserId?,
|
||||
recipientAddress: String?,
|
||||
amountLovelace: Long?,
|
||||
) {
|
||||
callback.navigateToPaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace)
|
||||
}
|
||||
|
||||
private fun displaySameRoomToast() {
|
||||
context.toast(CommonStrings.screen_room_permalink_same_room_android)
|
||||
}
|
||||
|
|
@ -294,6 +304,7 @@ class MessagesNode(
|
|||
callback.navigateToRoomCall(room.roomId, isAudioCall)
|
||||
},
|
||||
onViewAllPinnedMessagesClick = callback::navigateToPinnedMessagesList,
|
||||
onWalletClick = callback::navigateToWallet,
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {
|
||||
knockRequestsBannerRenderer.View(
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ class MessagesPresenter(
|
|||
dmUserVerificationState = dmUserVerificationState,
|
||||
roomMemberModerationState = roomMemberModerationState,
|
||||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||
isDmRoom = roomInfo.isDm,
|
||||
successorRoom = roomInfo.successorRoom,
|
||||
threads = Threads(
|
||||
hasThreads = canOpenThreadList && threadsList.isNotEmpty(),
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ data class MessagesState(
|
|||
val roomMemberModerationState: RoomMemberModerationState,
|
||||
/** Type of "shared history" icon to show in the top bar. */
|
||||
val topBarSharedHistoryIcon: SharedHistoryIcon,
|
||||
val isDmRoom: Boolean,
|
||||
val successorRoom: SuccessorRoom?,
|
||||
val threads: Threads,
|
||||
val eventSink: (MessagesEvent) -> Unit
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ fun aMessagesState(
|
|||
dmUserVerificationState: IdentityState? = null,
|
||||
roomMemberModerationState: RoomMemberModerationState = aRoomMemberModerationState(),
|
||||
topBarSharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
|
||||
isDmRoom: Boolean = false,
|
||||
successorRoom: SuccessorRoom? = null,
|
||||
threads: MessagesState.Threads = MessagesState.Threads(
|
||||
hasThreads = false,
|
||||
|
|
@ -153,6 +154,7 @@ fun aMessagesState(
|
|||
dmUserVerificationState = dmUserVerificationState,
|
||||
roomMemberModerationState = roomMemberModerationState,
|
||||
topBarSharedHistoryIcon = topBarSharedHistoryIcon,
|
||||
isDmRoom = isDmRoom,
|
||||
successorRoom = successorRoom,
|
||||
threads = threads,
|
||||
eventSink = eventSink,
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ import io.element.android.libraries.designsystem.text.toAnnotatedString
|
|||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
|
||||
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.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.HideKeyboardWhenDisposed
|
||||
|
|
@ -139,6 +140,7 @@ fun MessagesView(
|
|||
onSendLocationClick: () -> Unit,
|
||||
onCreatePollClick: () -> Unit,
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
onWalletClick: () -> Unit,
|
||||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
onThreadsListClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -241,7 +243,9 @@ fun MessagesView(
|
|||
displayThreads = state.timelineState.timelineMode !is Timeline.Mode.Thread && state.threads.hasThreads,
|
||||
roomCallState = state.roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onThreadsListClick = onThreadsListClick
|
||||
onThreadsListClick = onThreadsListClick,
|
||||
isDmRoom = state.isDmRoom,
|
||||
onWalletClick = onWalletClick,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -283,6 +287,7 @@ fun MessagesView(
|
|||
},
|
||||
forceJumpToBottomVisibility = forceJumpToBottomVisibility,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
onWalletClick = onWalletClick,
|
||||
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
|
||||
knockRequestsBannerView = knockRequestsBannerView,
|
||||
)
|
||||
|
|
@ -417,6 +422,8 @@ internal fun MessagesMenuActions(
|
|||
roomCallState: RoomCallState,
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
onThreadsListClick: () -> Unit,
|
||||
isDmRoom: Boolean = false,
|
||||
onWalletClick: (() -> Unit)? = null,
|
||||
) {
|
||||
if (displayThreads) {
|
||||
Icon(
|
||||
|
|
@ -430,6 +437,16 @@ internal fun MessagesMenuActions(
|
|||
roomCallState = roomCallState,
|
||||
onJoinCallClick = onJoinCallClick,
|
||||
)
|
||||
// Wallet button - only show in DM rooms
|
||||
if (isDmRoom && onWalletClick != null) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(onClick = onWalletClick) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Chart(),
|
||||
contentDescription = "Cardano Wallet",
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
|
||||
|
|
@ -461,6 +478,7 @@ private fun MessagesViewContent(
|
|||
onSendLocationClick: () -> Unit,
|
||||
onCreatePollClick: () -> Unit,
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
onWalletClick: () -> Unit,
|
||||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
forceJumpToBottomVisibility: Boolean,
|
||||
onSwipeToReply: (TimelineItem.Event) -> Unit,
|
||||
|
|
@ -634,6 +652,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
|
|||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onWalletClick = {},
|
||||
onViewAllPinnedMessagesClick = { },
|
||||
forceJumpToBottomVisibility = true,
|
||||
knockRequestsBannerView = {},
|
||||
|
|
@ -689,6 +708,7 @@ internal fun MessagesViewA11yPreview() = ElementPreview {
|
|||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onWalletClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
onThreadsListClick = {},
|
||||
forceJumpToBottomVisibility = true,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
|
||||
import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
|
|
@ -318,6 +319,9 @@ private fun MessageSummary(
|
|||
is TimelineItemRtcNotificationContent -> {
|
||||
content = { ContentForBody(stringResource(CommonStrings.common_call_started)) }
|
||||
}
|
||||
is TimelineItemPaymentContentWrapper -> {
|
||||
content = { ContentForBody(textContent) }
|
||||
}
|
||||
}
|
||||
Row(modifier = modifier) {
|
||||
icon()
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
|||
onSendLocationClick = {},
|
||||
onCreatePollClick = {},
|
||||
onJoinCallClick = {},
|
||||
onWalletClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {},
|
||||
onThreadsListClick = {},
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import io.element.android.features.messages.impl.MessagesNavigator
|
|||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
import io.element.android.features.messages.impl.attachments.Attachment.Media
|
||||
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
|
||||
import io.element.android.features.wallet.impl.slash.ParsedPayCommand
|
||||
import io.element.android.features.wallet.impl.slash.SlashCommandParser
|
||||
import io.element.android.features.messages.impl.draft.ComposerDraftService
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.RoomAliasSuggestionsDataSource
|
||||
import io.element.android.features.messages.impl.messagecomposer.suggestions.SuggestionsProcessor
|
||||
|
|
@ -102,6 +104,7 @@ import kotlinx.coroutines.flow.merge
|
|||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
||||
|
||||
|
|
@ -132,6 +135,7 @@ class MessageComposerPresenter(
|
|||
private val suggestionsProcessor: SuggestionsProcessor,
|
||||
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
|
||||
private val notificationConversationService: NotificationConversationService,
|
||||
private val slashCommandParser: SlashCommandParser,
|
||||
private val slashCommandService: SlashCommandService,
|
||||
) : Presenter<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
|
|
@ -461,6 +465,55 @@ class MessageComposerPresenter(
|
|||
val message = currentComposerMessage(markdownTextEditorState, richTextEditorState, withMentions = true)
|
||||
val capturedMode = messageComposerContext.composerMode
|
||||
|
||||
// Check for /pay slash command FIRST (our Cardano wallet integration).
|
||||
// If matched, short-circuit before upstream's slash command service runs.
|
||||
if (capturedMode is MessageComposerMode.Normal) {
|
||||
val payCommand = parsePayCommand(message.markdown)
|
||||
if (payCommand != null) {
|
||||
when (payCommand) {
|
||||
is ParsedPayCommand.ParseError -> {
|
||||
// Show error, keep text in composer
|
||||
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_error))
|
||||
return@launch
|
||||
}
|
||||
is ParsedPayCommand.WithAddressRecipient -> {
|
||||
// Reset composer and navigate to payment flow
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
recipientAddress = payCommand.address,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
is ParsedPayCommand.WithMatrixRecipient -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
recipientUserId = payCommand.matrixUserId,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
is ParsedPayCommand.AmountOnly -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
amountLovelace = payCommand.amount,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
is ParsedPayCommand.Empty -> {
|
||||
resetComposer(markdownTextEditorState, richTextEditorState, fromEdit = false)
|
||||
navigator.navigateToPaymentFlow(
|
||||
roomId = room.roomId,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val slashCommand = if (capturedMode is MessageComposerMode.Normal) {
|
||||
slashCommandService.parse(
|
||||
textMessage = message.markdown,
|
||||
|
|
@ -850,4 +903,14 @@ class MessageComposerPresenter(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the message text for /pay slash command.
|
||||
*
|
||||
* @param messageText The raw message text
|
||||
* @return ParsedPayCommand if this is a /pay command, null otherwise
|
||||
*/
|
||||
private fun parsePayCommand(messageText: String): ParsedPayCommand? {
|
||||
return slashCommandParser.parse(messageText)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
|
|||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.room.roomMembers
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandService
|
||||
import io.element.android.libraries.slashcommands.api.SlashCommandSuggestion
|
||||
import io.element.android.libraries.textcomposer.mentions.ResolvedSuggestion
|
||||
import io.element.android.libraries.textcomposer.model.Suggestion
|
||||
import io.element.android.libraries.textcomposer.model.SuggestionType
|
||||
|
|
@ -77,9 +78,23 @@ class SuggestionsProcessor(
|
|||
SuggestionType.Command -> {
|
||||
// Command suggestions are valid only if this is the beginning of the message
|
||||
if (suggestion.start == 0) {
|
||||
slashCommandService.getSuggestions(suggestion.text, isInThread).map {
|
||||
val upstream = slashCommandService.getSuggestions(suggestion.text, isInThread).map {
|
||||
ResolvedSuggestion.Command(it)
|
||||
}
|
||||
val wallet = if ("pay".startsWith(suggestion.text, ignoreCase = true)) {
|
||||
listOf(
|
||||
ResolvedSuggestion.Command(
|
||||
SlashCommandSuggestion(
|
||||
command = "pay",
|
||||
parameters = "[recipient] [amount]",
|
||||
description = "Send ADA to someone",
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
upstream + wallet
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ class ThreadedMessagesNode(
|
|||
fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean)
|
||||
fun navigateToThread(threadRootId: ThreadId, focusedEventId: EventId?)
|
||||
fun navigateToDeveloperSettings()
|
||||
fun navigateToPaymentFlow(roomId: RoomId, recipientUserId: UserId?, recipientAddress: String?, amountLovelace: Long?)
|
||||
}
|
||||
|
||||
override fun onBuilt() {
|
||||
|
|
@ -246,6 +247,15 @@ class ThreadedMessagesNode(
|
|||
callback.navigateToDeveloperSettings()
|
||||
}
|
||||
|
||||
override fun navigateToPaymentFlow(
|
||||
roomId: RoomId,
|
||||
recipientUserId: UserId?,
|
||||
recipientAddress: String?,
|
||||
amountLovelace: Long?,
|
||||
) {
|
||||
callback.navigateToPaymentFlow(roomId, recipientUserId, recipientAddress, amountLovelace)
|
||||
}
|
||||
|
||||
override fun close() = navigateUp()
|
||||
|
||||
@Composable
|
||||
|
|
@ -297,6 +307,7 @@ class ThreadedMessagesNode(
|
|||
onJoinCallClick = { isAudioCall ->
|
||||
callback.navigateToRoomCall(room.roomId, isAudioCall)
|
||||
},
|
||||
onWalletClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.wallet.impl.timeline.TimelineItemPaymentView
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.wysiwyg.link.Link
|
||||
|
|
@ -134,6 +136,10 @@ fun TimelineItemEventContentView(
|
|||
modifier = modifier
|
||||
)
|
||||
}
|
||||
is TimelineItemPaymentContentWrapper -> TimelineItemPaymentView(
|
||||
content = content.paymentContent,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemRtcNotificationContent -> error("This shouldn't be rendered as the content of a bubble")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
|||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
|
||||
import io.element.android.features.wallet.impl.timeline.TimelineItemContentPaymentFactory
|
||||
|
||||
@Inject
|
||||
class TimelineItemContentFactory(
|
||||
|
|
@ -49,9 +52,25 @@ class TimelineItemContentFactory(
|
|||
private val stateFactory: TimelineItemContentStateFactory,
|
||||
private val failedToParseMessageFactory: TimelineItemContentFailedToParseMessageFactory,
|
||||
private val failedToParseStateFactory: TimelineItemContentFailedToParseStateFactory,
|
||||
private val paymentFactory: TimelineItemContentPaymentFactory,
|
||||
private val sessionId: SessionId,
|
||||
) {
|
||||
suspend fun create(eventTimelineItem: EventTimelineItem): TimelineItemEventContent {
|
||||
val isOutgoing = sessionId == eventTimelineItem.sender
|
||||
|
||||
// Check for custom event types that we handle specially
|
||||
val content = eventTimelineItem.content
|
||||
if (content is CustomEventContent && paymentFactory.isPaymentEventType(content.eventType)) {
|
||||
// Try to get raw JSON from debug info for payment events
|
||||
val rawJson = eventTimelineItem.timelineItemDebugInfoProvider().originalJson
|
||||
if (rawJson != null) {
|
||||
val paymentContent = paymentFactory.createFromRaw(rawJson, isOutgoing)
|
||||
if (paymentContent != null) {
|
||||
return TimelineItemPaymentContentWrapper(paymentContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return create(
|
||||
itemContent = eventTimelineItem.content,
|
||||
eventId = eventTimelineItem.eventId,
|
||||
|
|
@ -99,6 +118,10 @@ class TimelineItemContentFactory(
|
|||
is UnableToDecryptContent -> utdFactory.create(itemContent)
|
||||
is CallNotifyContent -> TimelineItemRtcNotificationContent()
|
||||
is UnknownContent -> TimelineItemUnknownContent
|
||||
is CustomEventContent -> {
|
||||
// Custom events that weren't handled above (e.g., unknown custom event types)
|
||||
TimelineItemUnknownContent
|
||||
}
|
||||
is LiveLocationContent -> {
|
||||
val lastKnownLocation = itemContent.locations.mapNotNull { beacon ->
|
||||
Location.fromGeoUri(beacon.geoUri)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
|
||||
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
|
||||
import io.element.android.libraries.androidutils.text.safeLinkify
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
|
|
@ -254,16 +255,7 @@ class TimelineItemContentMessageFactory(
|
|||
}
|
||||
is TextMessageType -> {
|
||||
val body = messageType.body.trimEnd()
|
||||
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
val formattedBody = dom?.let(::parseHtml)
|
||||
?: textPillificationHelper.pillify(body).safeLinkify()
|
||||
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
TimelineItemTextContent(
|
||||
body = body,
|
||||
htmlDocument = htmlDocument,
|
||||
formattedBody = formattedBody,
|
||||
isEdited = content.isEdited,
|
||||
)
|
||||
createTextContent(body, messageType, content.isEdited)
|
||||
}
|
||||
is OtherMessageType -> {
|
||||
val body = messageType.body.trimEnd()
|
||||
|
|
@ -277,6 +269,23 @@ class TimelineItemContentMessageFactory(
|
|||
}
|
||||
}
|
||||
|
||||
private fun createTextContent(
|
||||
body: String,
|
||||
messageType: TextMessageType,
|
||||
isEdited: Boolean,
|
||||
): TimelineItemTextContent {
|
||||
val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
val formattedBody = dom?.let(::parseHtml)
|
||||
?: textPillificationHelper.pillify(body).safeLinkify()
|
||||
val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser)
|
||||
return TimelineItemTextContent(
|
||||
body = body,
|
||||
htmlDocument = htmlDocument,
|
||||
formattedBody = formattedBody,
|
||||
isEdited = isEdited,
|
||||
)
|
||||
}
|
||||
|
||||
private fun aspectRatioOf(width: Long?, height: Long?): Float? {
|
||||
val result = if (height != null && width != null) {
|
||||
width.toFloat() / height.toFloat()
|
||||
|
|
|
|||
|
|
@ -26,8 +26,10 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.CustomEventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
|
||||
|
|
@ -63,6 +65,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
|
|||
TimelineItemUnknownContent,
|
||||
is TimelineItemLegacyCallInviteContent,
|
||||
is TimelineItemRtcNotificationContent -> false
|
||||
is TimelineItemPaymentContentWrapper -> false
|
||||
is TimelineItemProfileChangeContent,
|
||||
is TimelineItemRoomMembershipContent,
|
||||
is TimelineItemStateEventContent -> true
|
||||
|
|
@ -91,6 +94,7 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean {
|
|||
UnknownContent,
|
||||
is LegacyCallInviteContent,
|
||||
CallNotifyContent,
|
||||
is StateContent -> false
|
||||
is StateContent,
|
||||
is CustomEventContent -> false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ fun TimelineItemEventContent.canReact(): Boolean =
|
|||
is TimelineItemRedactedContent,
|
||||
is TimelineItemLegacyCallInviteContent,
|
||||
is TimelineItemRtcNotificationContent,
|
||||
TimelineItemUnknownContent -> false
|
||||
TimelineItemUnknownContent,
|
||||
is TimelineItemPaymentContentWrapper -> false
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.wallet.api.PaymentCardStatus
|
||||
import io.element.android.features.wallet.api.timeline.TimelineItemPaymentContent
|
||||
|
||||
/**
|
||||
* Wrapper for [TimelineItemPaymentContent] that implements [TimelineItemEventContent].
|
||||
*
|
||||
* This wrapper is necessary because [TimelineItemEventContent] is a sealed interface
|
||||
* that must have all implementers in the same module. Since the wallet module
|
||||
* cannot add types to the sealed hierarchy, we wrap the payment content here.
|
||||
*/
|
||||
@Immutable
|
||||
data class TimelineItemPaymentContentWrapper(
|
||||
val paymentContent: TimelineItemPaymentContent,
|
||||
) : TimelineItemEventContent {
|
||||
override val type: String = paymentContent.type
|
||||
|
||||
// Delegate properties for convenience
|
||||
val amountLovelace: Long get() = paymentContent.amountLovelace
|
||||
val toAddress: String get() = paymentContent.toAddress
|
||||
val fromAddress: String get() = paymentContent.fromAddress
|
||||
val txHash: String? get() = paymentContent.txHash
|
||||
val status: PaymentCardStatus get() = paymentContent.status
|
||||
val network: String get() = paymentContent.network
|
||||
val isSentByMe: Boolean get() = paymentContent.isSentByMe
|
||||
val fallbackText: String get() = paymentContent.fallbackText
|
||||
val amountAda: String get() = paymentContent.amountAda
|
||||
val isTestnet: Boolean get() = paymentContent.isTestnet
|
||||
val truncatedTxHash: String? get() = paymentContent.truncatedTxHash
|
||||
val truncatedToAddress: String get() = paymentContent.truncatedToAddress
|
||||
val truncatedFromAddress: String get() = paymentContent.truncatedFromAddress
|
||||
val explorerUrl: String? get() = paymentContent.explorerUrl
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
|
||||
|
||||
/**
|
||||
* Return true if the event must be hidden by default when the setting to hide images and videos is enabled.
|
||||
|
|
@ -53,7 +54,8 @@ fun TimelineItem.mustBeProtected(): Boolean {
|
|||
is TimelineItemNoticeContent,
|
||||
is TimelineItemTextContent,
|
||||
TimelineItemUnknownContent,
|
||||
is TimelineItemVoiceContent -> false
|
||||
is TimelineItemVoiceContent,
|
||||
is TimelineItemPaymentContentWrapper -> false
|
||||
}
|
||||
is TimelineItem.Virtual -> false
|
||||
is TimelineItem.GroupedEvents -> false
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
|
|||
roomCallState: RoomCallState = RoomCallState.Unavailable,
|
||||
dmUserIdentityState: IdentityState? = null,
|
||||
sharedHistoryIcon: SharedHistoryIcon = SharedHistoryIcon.NONE,
|
||||
isDmRoom: Boolean = false,
|
||||
displayThreads: Boolean = false,
|
||||
) = MessagesViewTopBar(
|
||||
roomName = roomName,
|
||||
|
|
@ -194,6 +195,8 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
|
|||
displayThreads = displayThreads,
|
||||
onJoinCallClick = {},
|
||||
onThreadsListClick = {},
|
||||
isDmRoom = isDmRoom,
|
||||
onWalletClick = {},
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -217,7 +220,8 @@ internal fun MessagesViewTopBarPreview() = ElementPreview {
|
|||
url = "https://some-avatar.jpg"
|
||||
),
|
||||
roomCallState = aStandByCallState(canStartCall = false),
|
||||
dmUserIdentityState = IdentityState.Verified
|
||||
dmUserIdentityState = IdentityState.Verified,
|
||||
isDmRoom = true,
|
||||
)
|
||||
HorizontalDivider()
|
||||
AMessagesViewTopBar(
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPaymentContentWrapper
|
||||
import io.element.android.libraries.core.extensions.toSafeLength
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
|
|
@ -54,6 +55,7 @@ class DefaultMessageSummaryFormatter(
|
|||
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
|
||||
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
|
||||
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started)
|
||||
is TimelineItemPaymentContentWrapper -> "Payment"
|
||||
}
|
||||
// Truncate the message to a safe length to avoid crashes in Compose
|
||||
.toSafeLength()
|
||||
|
|
|
|||
18
features/wallet/api/build.gradle.kts
Normal file
18
features/wallet/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.wallet.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
7
features/wallet/api/src/main/AndroidManifest.xml
Normal file
7
features/wallet/api/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Sulkta Coop.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<manifest />
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Client interface for interacting with the Cardano blockchain.
|
||||
*
|
||||
* All methods are suspend functions and return [Result] to handle errors gracefully.
|
||||
* Implementations should handle retries, rate limiting, and network errors internally.
|
||||
*/
|
||||
interface CardanoClient {
|
||||
/**
|
||||
* Get the balance (in lovelace) for a given Cardano address.
|
||||
*
|
||||
* @param address Bech32 Cardano address (addr1... or addr_test1...)
|
||||
* @return Balance in lovelace (1 ADA = 1,000,000 lovelace)
|
||||
*/
|
||||
suspend fun getBalance(address: String): Result<Long>
|
||||
|
||||
/**
|
||||
* Get all unspent transaction outputs (UTxOs) for a given address.
|
||||
*
|
||||
* @param address Bech32 Cardano address
|
||||
* @return List of [Utxo] objects representing available outputs
|
||||
*/
|
||||
suspend fun getUtxos(address: String): Result<List<Utxo>>
|
||||
|
||||
/**
|
||||
* Submit a signed transaction to the Cardano network.
|
||||
*
|
||||
* @param signedTxCbor CBOR-encoded signed transaction as hex string
|
||||
* @return Transaction hash on success
|
||||
*/
|
||||
suspend fun submitTx(signedTxCbor: String): Result<String>
|
||||
|
||||
/**
|
||||
* Get the current status of a transaction.
|
||||
*
|
||||
* @param txHash Transaction hash to query
|
||||
* @return Current [TxStatus] of the transaction
|
||||
*/
|
||||
suspend fun getTxStatus(txHash: String): Result<TxStatus>
|
||||
|
||||
/**
|
||||
* Get the current protocol parameters from the network.
|
||||
*
|
||||
* Protocol parameters are needed for fee calculation and transaction building.
|
||||
*
|
||||
* @return Current [ProtocolParameters] from the latest epoch
|
||||
*/
|
||||
suspend fun getProtocolParameters(): Result<ProtocolParameters>
|
||||
|
||||
/**
|
||||
* Get native assets (tokens) for a given address.
|
||||
*
|
||||
* @param address Bech32 Cardano address
|
||||
* @return List of [NativeAsset] objects
|
||||
*/
|
||||
suspend fun getAddressAssets(address: String): Result<List<NativeAsset>>
|
||||
|
||||
/**
|
||||
* Get transaction history for a given address.
|
||||
*
|
||||
* @param address Bech32 Cardano address
|
||||
* @param limit Maximum number of transactions to return (default 20)
|
||||
* @return List of [TxSummary] objects, most recent first
|
||||
*/
|
||||
suspend fun getAddressTransactions(address: String, limit: Int = 20): Result<List<TxSummary>>
|
||||
|
||||
/**
|
||||
* Resolve an ADA Handle to a Cardano address.
|
||||
*
|
||||
* ADA Handles are human-readable names (e.g., $cobb) that resolve to Cardano addresses.
|
||||
* Handle resolution is case-insensitive.
|
||||
*
|
||||
* @param handle Handle name WITHOUT the $ prefix (e.g., "cobb" not "$cobb")
|
||||
* @return Bech32 Cardano address if handle exists, null if not found
|
||||
*/
|
||||
suspend fun resolveHandle(handle: String): Result<String?>
|
||||
|
||||
/**
|
||||
* Get CIP-25 NFT metadata for a specific asset.
|
||||
*
|
||||
* Uses the Koios asset_info endpoint to fetch onchain_metadata.
|
||||
*
|
||||
* @param policyId The minting policy ID (hex, 56 chars)
|
||||
* @param assetName The asset name (hex encoded)
|
||||
* @return [NftMetadata] if CIP-25 metadata exists, null otherwise
|
||||
*/
|
||||
suspend fun getNftMetadata(policyId: String, assetName: String): Result<NftMetadata?>
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Base exception for Cardano-related errors.
|
||||
*/
|
||||
sealed class CardanoException(
|
||||
override val message: String,
|
||||
override val cause: Throwable? = null,
|
||||
) : Exception(message, cause) {
|
||||
|
||||
/**
|
||||
* Network connectivity or API error.
|
||||
*/
|
||||
class NetworkException(
|
||||
message: String,
|
||||
val statusCode: Int? = null,
|
||||
cause: Throwable? = null,
|
||||
) : CardanoException(message, cause)
|
||||
|
||||
/**
|
||||
* Rate limit exceeded (HTTP 429).
|
||||
*/
|
||||
class RateLimitException(
|
||||
message: String = "Rate limit exceeded",
|
||||
val retryAfterMs: Long? = null,
|
||||
) : CardanoException(message)
|
||||
|
||||
/**
|
||||
* Invalid Cardano address format.
|
||||
*/
|
||||
class InvalidAddressException(
|
||||
val address: String,
|
||||
) : CardanoException("Invalid Cardano address: $address")
|
||||
|
||||
/**
|
||||
* Transaction not found on chain.
|
||||
*/
|
||||
class TransactionNotFoundException(
|
||||
val txHash: String,
|
||||
) : CardanoException("Transaction not found: $txHash")
|
||||
|
||||
/**
|
||||
* Transaction submission failed.
|
||||
*/
|
||||
class SubmissionFailedException(
|
||||
message: String,
|
||||
val errorCode: String? = null,
|
||||
cause: Throwable? = null,
|
||||
) : CardanoException(message, cause)
|
||||
|
||||
/**
|
||||
* Insufficient funds to complete transaction.
|
||||
*/
|
||||
class InsufficientFundsException(
|
||||
val required: Long,
|
||||
val available: Long,
|
||||
) : CardanoException("Insufficient funds: required $required lovelace, available $available lovelace")
|
||||
|
||||
/**
|
||||
* Generic API error for unexpected responses.
|
||||
*/
|
||||
class ApiException(
|
||||
message: String,
|
||||
val response: String? = null,
|
||||
cause: Throwable? = null,
|
||||
) : CardanoException(message, cause)
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Represents a native asset (token) on Cardano.
|
||||
*
|
||||
* @property policyId The minting policy ID (hex)
|
||||
* @property assetName The asset name (hex or decoded)
|
||||
* @property quantity The amount of this asset
|
||||
* @property displayName Human-readable name if available
|
||||
* @property fingerprint The asset fingerprint (CIP-14)
|
||||
* @property imageUrl Resolved image URL (IPFS gateway or HTTPS) for NFTs
|
||||
* @property decimals Decimal places for fungible tokens (null for NFTs)
|
||||
* @property ticker Token ticker symbol (e.g., "HOSKY")
|
||||
* @property description Token/NFT description
|
||||
* @property isNft True if this is likely an NFT (quantity == 1 with image metadata)
|
||||
*/
|
||||
data class NativeAsset(
|
||||
val policyId: String,
|
||||
val assetName: String,
|
||||
val quantity: Long,
|
||||
val displayName: String?,
|
||||
val fingerprint: String?,
|
||||
val imageUrl: String? = null,
|
||||
val decimals: Int? = null,
|
||||
val ticker: String? = null,
|
||||
val description: String? = null,
|
||||
val isNft: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* Truncated policy ID for display.
|
||||
*/
|
||||
val truncatedPolicyId: String
|
||||
get() = if (policyId.length > 16) {
|
||||
"${policyId.take(8)}...${policyId.takeLast(8)}"
|
||||
} else {
|
||||
policyId
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name, falling back to truncated asset name.
|
||||
*/
|
||||
val name: String
|
||||
get() = displayName ?: ticker ?: assetName.takeIf { it.isNotEmpty() }?.let {
|
||||
// Try to decode hex to ASCII if it looks printable
|
||||
try {
|
||||
val decoded = it.chunked(2).map { hex -> hex.toInt(16).toChar() }.joinToString("")
|
||||
if (decoded.all { c -> c.isLetterOrDigit() || c in " -_" }) decoded else it
|
||||
} catch (_: Exception) {
|
||||
it
|
||||
}
|
||||
} ?: "Unknown"
|
||||
|
||||
/**
|
||||
* Unit string for this asset (concatenated policyId + assetName).
|
||||
*/
|
||||
val unit: String
|
||||
get() = "$policyId$assetName"
|
||||
|
||||
/**
|
||||
* Format quantity with decimals for display.
|
||||
*/
|
||||
fun formatQuantity(): String {
|
||||
return if (decimals != null && decimals > 0) {
|
||||
val divisor = Math.pow(10.0, decimals.toDouble())
|
||||
String.format("%.${decimals}f", quantity / divisor).trimEnd('0').trimEnd('.')
|
||||
} else {
|
||||
quantity.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* CIP-25 NFT metadata parsed from Koios asset_info response.
|
||||
*
|
||||
* @property name The NFT name
|
||||
* @property image Resolved HTTP URL for the image (IPFS gateway or direct HTTPS)
|
||||
* @property description NFT description if available
|
||||
* @property rawMetadata Original metadata map for additional fields
|
||||
*/
|
||||
data class NftMetadata(
|
||||
val name: String,
|
||||
val image: String?,
|
||||
val description: String?,
|
||||
val rawMetadata: Map<String, Any>,
|
||||
) {
|
||||
companion object {
|
||||
private const val IPFS_GATEWAY = "https://ipfs.io/ipfs/"
|
||||
|
||||
/**
|
||||
* Resolve IPFS URLs to HTTP gateway URLs.
|
||||
*/
|
||||
fun resolveImageUrl(url: String?): String? {
|
||||
if (url == null) return null
|
||||
return when {
|
||||
url.startsWith("ipfs://") -> IPFS_GATEWAY + url.removePrefix("ipfs://")
|
||||
url.startsWith("Qm") -> IPFS_GATEWAY + url // Direct IPFS hash
|
||||
url.startsWith("https://") || url.startsWith("http://") -> url
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join array-based image URL (some NFTs split the URL across multiple strings).
|
||||
*/
|
||||
fun joinImageParts(parts: List<String>?): String? {
|
||||
if (parts.isNullOrEmpty()) return null
|
||||
return resolveImageUrl(parts.joinToString(""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Status of a Cardano payment transaction.
|
||||
*/
|
||||
enum class PaymentCardStatus {
|
||||
/** Transaction submitted but not yet confirmed on chain */
|
||||
PENDING,
|
||||
/** Transaction confirmed on chain */
|
||||
CONFIRMED,
|
||||
/** Transaction failed or was rejected */
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
|
||||
/**
|
||||
* Interface for sending Cardano payment events to Matrix rooms.
|
||||
*/
|
||||
interface PaymentEventSender {
|
||||
/**
|
||||
* Send a payment event to the room timeline.
|
||||
*
|
||||
* This creates a specially formatted message that wallet-enabled clients
|
||||
* can render as a payment card, while non-wallet clients see a fallback text.
|
||||
*
|
||||
* @param timeline The room timeline to send the event to
|
||||
* @param request The payment request details
|
||||
* @param signedTx The signed transaction (contains tx hash)
|
||||
* @param network The Cardano network (mainnet/testnet)
|
||||
* @return Result indicating success or failure
|
||||
*/
|
||||
suspend fun sendPaymentEvent(
|
||||
timeline: Timeline,
|
||||
request: PaymentRequest,
|
||||
signedTx: SignedTransaction,
|
||||
network: String,
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
* Send a payment status update event.
|
||||
*
|
||||
* Used when a transaction's status changes (e.g., pending → confirmed).
|
||||
*
|
||||
* @param timeline The room timeline
|
||||
* @param txHash The transaction hash
|
||||
* @param newStatus The new status
|
||||
* @param network The Cardano network
|
||||
* @return Result indicating success or failure
|
||||
*/
|
||||
suspend fun sendStatusUpdate(
|
||||
timeline: Timeline,
|
||||
txHash: String,
|
||||
newStatus: String,
|
||||
network: String,
|
||||
): Result<Unit>
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* A request to build and sign a Cardano payment transaction.
|
||||
*
|
||||
* @property fromAddress The sender's Cardano address (Bech32)
|
||||
* @property toAddress The recipient's Cardano address (Bech32)
|
||||
* @property amountLovelace The amount of ADA to send in lovelace (1 ADA = 1,000,000 lovelace).
|
||||
* For token-only sends, this should be the minimum UTXO (~1.5 ADA).
|
||||
* @property sessionId The Matrix session ID for key retrieval
|
||||
* @property assetPolicyId Policy ID of the native asset to send (null for ADA-only)
|
||||
* @property assetName Asset name in hex (null for ADA-only)
|
||||
* @property assetQuantity Quantity of the native asset to send (null for ADA-only)
|
||||
*/
|
||||
data class PaymentRequest(
|
||||
val fromAddress: String,
|
||||
val toAddress: String,
|
||||
val amountLovelace: Long,
|
||||
val sessionId: SessionId,
|
||||
val assetPolicyId: String? = null,
|
||||
val assetName: String? = null,
|
||||
val assetQuantity: Long? = null,
|
||||
) {
|
||||
/**
|
||||
* True if this request includes a native asset (token) send.
|
||||
*/
|
||||
val hasAsset: Boolean
|
||||
get() = assetPolicyId != null && assetName != null && assetQuantity != null && assetQuantity > 0
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Interface for polling transaction confirmation status.
|
||||
*/
|
||||
interface PaymentStatusPoller {
|
||||
/**
|
||||
* Polls for transaction confirmation status.
|
||||
*
|
||||
* Emits [TxStatus] changes as a Flow:
|
||||
* - Initially PENDING
|
||||
* - CONFIRMED when transaction is in a block
|
||||
* - FAILED if confirmation times out or error occurs
|
||||
*
|
||||
* Polling behavior:
|
||||
* - Poll every 10 seconds
|
||||
* - Maximum 60 attempts (~10 minutes total)
|
||||
* - Stops when status changes from PENDING
|
||||
*
|
||||
* @param txHash The transaction hash to poll
|
||||
* @return Flow of [TxStatus] changes
|
||||
*/
|
||||
fun pollUntilConfirmed(txHash: String): Flow<TxStatus>
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Cardano protocol parameters needed for transaction building and fee calculation.
|
||||
*
|
||||
* These parameters are set via governance and determine transaction costs
|
||||
* and constraints on the network.
|
||||
*
|
||||
* @property minFeeA The linear fee coefficient (lovelace per byte)
|
||||
* @property minFeeB The constant fee (base fee in lovelace)
|
||||
* @property maxTxSize Maximum transaction size in bytes
|
||||
* @property utxoCostPerByte Cost in lovelace per byte of UTXO storage (for min UTXO calculation)
|
||||
*/
|
||||
data class ProtocolParameters(
|
||||
val minFeeA: Long,
|
||||
val minFeeB: Long,
|
||||
val maxTxSize: Int,
|
||||
val utxoCostPerByte: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* A signed Cardano transaction ready for submission.
|
||||
*
|
||||
* @property txCbor The CBOR-encoded signed transaction as a hex string
|
||||
* @property txHash The transaction hash (for tracking)
|
||||
* @property fee The transaction fee in lovelace
|
||||
* @property actualAmount The actual amount sent (may differ slightly from requested due to min UTXO rules)
|
||||
*/
|
||||
data class SignedTransaction(
|
||||
val txCbor: String,
|
||||
val txHash: String,
|
||||
val fee: Long,
|
||||
val actualAmount: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Interface for building and signing Cardano transactions.
|
||||
*
|
||||
* The implementation handles:
|
||||
* - UTXO selection (largest-first coin selection)
|
||||
* - Fee calculation based on protocol parameters
|
||||
* - Change output calculation
|
||||
* - Transaction signing with the spending key
|
||||
*
|
||||
* ## Error handling
|
||||
* The following errors may be returned:
|
||||
* - [CardanoException.InsufficientFundsException] - Not enough ADA in wallet
|
||||
* - [CardanoException.InvalidAddressException] - Invalid address format
|
||||
* - [CardanoException.ApiException] - Various API/build errors
|
||||
*/
|
||||
interface TransactionBuilder {
|
||||
/**
|
||||
* Builds and signs a payment transaction.
|
||||
*
|
||||
* This method will:
|
||||
* 1. Fetch UTXOs for the sender address
|
||||
* 2. Select UTXOs to cover amount + fee (largest-first)
|
||||
* 3. Build the transaction with proper change output
|
||||
* 4. Retrieve the spending key (triggers biometric prompt)
|
||||
* 5. Sign the transaction
|
||||
* 6. Return the signed transaction ready for submission
|
||||
*
|
||||
* @param request The payment request details
|
||||
* @return [SignedTransaction] on success, error on failure
|
||||
*/
|
||||
suspend fun buildAndSign(request: PaymentRequest): Result<SignedTransaction>
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Transaction confirmation status on the Cardano blockchain.
|
||||
*/
|
||||
enum class TxStatus {
|
||||
/** Transaction submitted but not yet confirmed in a block. */
|
||||
PENDING,
|
||||
|
||||
/** Transaction confirmed in at least one block. */
|
||||
CONFIRMED,
|
||||
|
||||
/** Transaction failed or was rejected by the network. */
|
||||
FAILED,
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Summary of a Cardano transaction for history display.
|
||||
*
|
||||
* @property txHash The transaction hash
|
||||
* @property blockTime Unix timestamp when the tx was included in a block
|
||||
* @property totalOutput Total output in lovelace
|
||||
* @property fee Transaction fee in lovelace
|
||||
* @property direction Whether this was sent or received
|
||||
*/
|
||||
data class TxSummary(
|
||||
val txHash: String,
|
||||
val blockTime: Long,
|
||||
val totalOutput: Long,
|
||||
val fee: Long,
|
||||
val direction: Direction,
|
||||
) {
|
||||
enum class Direction {
|
||||
SENT,
|
||||
RECEIVED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted date for display.
|
||||
*/
|
||||
val formattedDate: String
|
||||
get() = try {
|
||||
val instant = Instant.ofEpochSecond(blockTime)
|
||||
val formatter = DateTimeFormatter.ofPattern("MMM d, yyyy")
|
||||
.withZone(ZoneId.systemDefault())
|
||||
formatter.format(instant)
|
||||
} catch (_: Exception) {
|
||||
"Unknown date"
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncated tx hash for display.
|
||||
*/
|
||||
val truncatedTxHash: String
|
||||
get() = if (txHash.length > 16) {
|
||||
"${txHash.take(8)}...${txHash.takeLast(8)}"
|
||||
} else {
|
||||
txHash
|
||||
}
|
||||
|
||||
/**
|
||||
* Amount formatted as ADA.
|
||||
*/
|
||||
val amountAda: String
|
||||
get() {
|
||||
val ada = totalOutput / 1_000_000.0
|
||||
return if (ada == ada.toLong().toDouble()) {
|
||||
"${ada.toLong()} ADA"
|
||||
} else {
|
||||
val formatted = "%.6f".format(ada).trimEnd('0').trimEnd('.')
|
||||
"$formatted ADA"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explorer URL for this transaction.
|
||||
*/
|
||||
fun explorerUrl(isTestnet: Boolean): String {
|
||||
return if (isTestnet) {
|
||||
"https://preprod.cardanoscan.io/transaction/$txHash"
|
||||
} else {
|
||||
"https://cardanoscan.io/transaction/$txHash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Represents an unspent transaction output (UTxO) on Cardano.
|
||||
*
|
||||
* @property txHash The transaction hash where this UTxO was created.
|
||||
* @property outputIndex The index of this output within the transaction.
|
||||
* @property amount The amount in lovelace (1 ADA = 1,000,000 lovelace).
|
||||
* @property address The address holding this UTxO.
|
||||
* @property assets Native assets (tokens) contained in this UTxO.
|
||||
*/
|
||||
data class Utxo(
|
||||
val txHash: String,
|
||||
val outputIndex: Int,
|
||||
val amount: Long,
|
||||
val address: String,
|
||||
val assets: List<UtxoAsset> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a native asset within a UTxO.
|
||||
*
|
||||
* @property policyId The minting policy ID (56 hex chars).
|
||||
* @property assetName The asset name (hex-encoded).
|
||||
* @property quantity The amount of this asset in the UTxO.
|
||||
*/
|
||||
data class UtxoAsset(
|
||||
val policyId: String,
|
||||
val assetName: String,
|
||||
val quantity: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
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.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Entry point for the Cardano wallet feature.
|
||||
* Provides navigation to payment flows and wallet management.
|
||||
*/
|
||||
interface WalletEntryPoint : FeatureEntryPoint {
|
||||
/**
|
||||
* Builder for creating wallet flow nodes.
|
||||
*/
|
||||
interface Builder {
|
||||
fun setRoomId(roomId: RoomId): Builder
|
||||
fun setRecipientUserId(userId: UserId?): Builder
|
||||
fun setRecipientAddress(address: String?): Builder
|
||||
fun setAmount(amount: String?): Builder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a builder for the payment flow.
|
||||
*/
|
||||
fun paymentFlowBuilder(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
callback: Callback,
|
||||
): Builder
|
||||
|
||||
/**
|
||||
* Callback for wallet flow events.
|
||||
*/
|
||||
interface Callback : Plugin {
|
||||
fun onPaymentSent(txHash: String)
|
||||
fun onPaymentCancelled()
|
||||
/** Called when user needs to set up wallet before paying. Caller should navigate to wallet panel. */
|
||||
fun onOpenWalletSettings()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api
|
||||
|
||||
/**
|
||||
* Represents the current state of the Cardano wallet.
|
||||
*/
|
||||
data class WalletState(
|
||||
val hasWallet: Boolean,
|
||||
val address: String?,
|
||||
val balanceLovelace: Long?,
|
||||
val balanceAda: String?,
|
||||
val isLoading: Boolean,
|
||||
val error: String?,
|
||||
) {
|
||||
companion object {
|
||||
val Initial = WalletState(
|
||||
hasWallet = false,
|
||||
address = null,
|
||||
balanceLovelace = null,
|
||||
balanceAda = null,
|
||||
isLoading = true,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api.address
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
|
||||
/**
|
||||
* Service for managing Cardano addresses in Matrix account data.
|
||||
*
|
||||
* This allows users to publish their Cardano address so other users can
|
||||
* look it up for payments - like a public address directory baked into Matrix.
|
||||
*
|
||||
* Account data key: `com.sulkta.cardano.address`
|
||||
* Content format: `{ "address": "addr1..." }`
|
||||
*/
|
||||
interface CardanoAddressService {
|
||||
/**
|
||||
* Publish the user's Cardano address to their Matrix account data.
|
||||
* This is public data, not encrypted.
|
||||
*
|
||||
* @param address The Cardano address to publish
|
||||
* @return Result indicating success or failure
|
||||
*/
|
||||
suspend fun publishAddress(address: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Look up another user's Cardano address from their Matrix account data.
|
||||
*
|
||||
* @param userId The Matrix user ID to look up
|
||||
* @return The user's Cardano address if published, null if not found
|
||||
*/
|
||||
suspend fun lookupAddress(userId: UserId): Result<String?>
|
||||
|
||||
companion object {
|
||||
const val ACCOUNT_DATA_TYPE = "com.sulkta.cardano.address"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a Cardano address lookup.
|
||||
*/
|
||||
sealed interface AddressLookupResult {
|
||||
/** Address was found and retrieved successfully */
|
||||
data class Found(val address: String, val userId: UserId) : AddressLookupResult
|
||||
|
||||
/** User has no Cardano address linked */
|
||||
data class NotLinked(val userId: UserId) : AddressLookupResult
|
||||
|
||||
/** Lookup failed due to an error */
|
||||
data class Error(val userId: UserId, val message: String) : AddressLookupResult
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api.backup
|
||||
|
||||
/**
|
||||
* Service for backing up and restoring wallet seed phrases using Matrix SSSS.
|
||||
*
|
||||
* The backup is encrypted with the user's Matrix recovery key and stored
|
||||
* in their account data, so it follows them across devices.
|
||||
*/
|
||||
interface WalletBackupService {
|
||||
/**
|
||||
* The secret name used to store the wallet seed in SSSS.
|
||||
*/
|
||||
companion object {
|
||||
const val SECRET_NAME = "com.sulkta.cardano.wallet_seed"
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup the wallet seed phrase to Matrix SSSS.
|
||||
*
|
||||
* @param recoveryKey The Matrix recovery key (base58 encoded)
|
||||
* @param mnemonic The wallet seed phrase to backup
|
||||
* @return Success or error
|
||||
*/
|
||||
suspend fun backupSeed(recoveryKey: String, mnemonic: List<String>): Result<Unit>
|
||||
|
||||
/**
|
||||
* Restore a wallet seed phrase from Matrix SSSS.
|
||||
*
|
||||
* @param recoveryKey The Matrix recovery key
|
||||
* @return The mnemonic words if found, null if no backup exists
|
||||
*/
|
||||
suspend fun restoreSeed(recoveryKey: String): Result<List<String>?>
|
||||
|
||||
/**
|
||||
* Check if a wallet backup exists in SSSS.
|
||||
*
|
||||
* This can be called with the recovery key to verify a backup is present.
|
||||
*
|
||||
* @param recoveryKey The Matrix recovery key
|
||||
* @return True if a backup exists, false otherwise
|
||||
*/
|
||||
suspend fun hasBackup(recoveryKey: String): Result<Boolean>
|
||||
|
||||
/**
|
||||
* Check if a wallet backup exists in account data WITHOUT decrypting.
|
||||
*
|
||||
* This checks the raw Matrix account data to see if the secret key exists,
|
||||
* without needing the recovery key. Useful for UI to show restore option.
|
||||
*
|
||||
* @return True if the account data key exists, false otherwise
|
||||
*/
|
||||
suspend fun hasBackupWithoutKey(): Result<Boolean>
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api.storage
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Result of wallet creation containing the generated seed phrase and derived addresses.
|
||||
*/
|
||||
data class WalletCreationResult(
|
||||
val mnemonic: List<String>,
|
||||
val baseAddress: String,
|
||||
val stakeAddress: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Interface for secure storage and retrieval of Cardano wallet keys.
|
||||
*
|
||||
* Wallets are scoped PER SESSION (per Matrix account). Each [SessionId] can have
|
||||
* exactly one wallet associated with it.
|
||||
*
|
||||
* ## Security Properties
|
||||
* - Keys are stored encrypted using Android Keystore
|
||||
* - Biometric/PIN authentication required for every signing operation
|
||||
* - Keys are INVALIDATED if biometric enrollment changes
|
||||
* - Mnemonic is stored encrypted, never in plaintext
|
||||
*
|
||||
* ## Implementation Notes
|
||||
* - Use `setInvalidatedByBiometricEnrollment(true)` for Keystore keys
|
||||
* - Use `setUserAuthenticationRequired(true)` with duration -1 (every time)
|
||||
* - Key alias format: "cardano_wallet_{sessionId}"
|
||||
*/
|
||||
interface CardanoKeyStorage {
|
||||
/**
|
||||
* Checks if a wallet exists for the given session.
|
||||
*/
|
||||
suspend fun hasWallet(sessionId: SessionId): Boolean
|
||||
|
||||
/**
|
||||
* Generates a new wallet with a 24-word BIP-39 mnemonic.
|
||||
*
|
||||
* @param sessionId The Matrix session to create the wallet for
|
||||
* @return [WalletCreationResult] containing the mnemonic and derived addresses
|
||||
* @throws IllegalStateException if a wallet already exists for this session
|
||||
*/
|
||||
suspend fun generateWallet(sessionId: SessionId): Result<WalletCreationResult>
|
||||
|
||||
/**
|
||||
* Imports an existing wallet from a mnemonic phrase.
|
||||
*
|
||||
* @param sessionId The Matrix session to import the wallet for
|
||||
* @param mnemonic The BIP-39 mnemonic phrase (12, 15, 18, 21, or 24 words)
|
||||
* @return The derived base address on success
|
||||
* @throws IllegalArgumentException if the mnemonic is invalid
|
||||
* @throws IllegalStateException if a wallet already exists for this session
|
||||
*/
|
||||
suspend fun importWallet(sessionId: SessionId, mnemonic: List<String>): Result<String>
|
||||
|
||||
/**
|
||||
* Retrieves the encrypted mnemonic for backup display.
|
||||
*
|
||||
* ⚠️ WARNING: This returns sensitive data. UI must use FLAG_SECURE.
|
||||
*
|
||||
* @param sessionId The Matrix session
|
||||
* @return The mnemonic word list
|
||||
*/
|
||||
suspend fun getMnemonic(sessionId: SessionId): Result<List<String>>
|
||||
|
||||
/**
|
||||
* Gets the base address (payment + staking key hash) for the wallet.
|
||||
*
|
||||
* @param sessionId The Matrix session
|
||||
* @param addressIndex The address index (default 0)
|
||||
*/
|
||||
suspend fun getBaseAddress(sessionId: SessionId, addressIndex: Int = 0): Result<String>
|
||||
|
||||
/**
|
||||
* Gets the staking/reward address for the wallet.
|
||||
*
|
||||
* @param sessionId The Matrix session
|
||||
*/
|
||||
suspend fun getStakeAddress(sessionId: SessionId): Result<String>
|
||||
|
||||
/**
|
||||
* Permanently deletes the wallet and all associated key material.
|
||||
*
|
||||
* @param sessionId The Matrix session
|
||||
*/
|
||||
suspend fun deleteWallet(sessionId: SessionId): Result<Unit>
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.api.timeline
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.wallet.api.PaymentCardStatus
|
||||
|
||||
/**
|
||||
* Timeline content for a Cardano payment event.
|
||||
*
|
||||
* This class represents payment event content and can be rendered
|
||||
* in the timeline. It does NOT inherit from TimelineItemEventContent
|
||||
* to avoid circular dependencies between wallet:api and messages:impl.
|
||||
*
|
||||
* The TimelineItemContentFactory handles this type specially.
|
||||
*
|
||||
* @property amountLovelace The payment amount in lovelace (1 ADA = 1,000,000 lovelace)
|
||||
* @property toAddress The recipient Cardano address (Bech32)
|
||||
* @property fromAddress The sender Cardano address (Bech32)
|
||||
* @property txHash The transaction hash (null if not yet submitted)
|
||||
* @property status Current status of the payment
|
||||
* @property network The Cardano network (mainnet/testnet)
|
||||
* @property isSentByMe True if the current user sent this payment
|
||||
* @property fallbackText Human-readable fallback text for non-wallet clients
|
||||
*/
|
||||
@Immutable
|
||||
data class TimelineItemPaymentContent(
|
||||
val amountLovelace: Long,
|
||||
val toAddress: String,
|
||||
val fromAddress: String,
|
||||
val txHash: String?,
|
||||
val status: PaymentCardStatus,
|
||||
val network: String,
|
||||
val isSentByMe: Boolean,
|
||||
val fallbackText: String,
|
||||
) {
|
||||
val type: String = EVENT_TYPE
|
||||
|
||||
/**
|
||||
* Amount formatted in ADA (lovelace / 1,000,000).
|
||||
*/
|
||||
val amountAda: String
|
||||
get() = formatAda(amountLovelace)
|
||||
|
||||
/**
|
||||
* Whether this is on testnet.
|
||||
*/
|
||||
val isTestnet: Boolean
|
||||
get() = network == "testnet" || network == "preprod" || network == "preview"
|
||||
|
||||
/**
|
||||
* Truncated tx hash for display (first 8 + last 8 chars).
|
||||
*/
|
||||
val truncatedTxHash: String?
|
||||
get() = txHash?.let { hash ->
|
||||
if (hash.length > 20) {
|
||||
"${hash.take(8)}...${hash.takeLast(8)}"
|
||||
} else {
|
||||
hash
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncated recipient address for display (first 8 + last 6 chars).
|
||||
*/
|
||||
val truncatedToAddress: String
|
||||
get() = truncateAddress(toAddress)
|
||||
|
||||
/**
|
||||
* Truncated sender address for display (first 8 + last 6 chars).
|
||||
*/
|
||||
val truncatedFromAddress: String
|
||||
get() = truncateAddress(fromAddress)
|
||||
|
||||
/**
|
||||
* CardanoScan URL for viewing the transaction.
|
||||
*/
|
||||
val explorerUrl: String?
|
||||
get() = txHash?.let { hash ->
|
||||
if (isTestnet) {
|
||||
"https://preprod.cardanoscan.io/transaction/$hash"
|
||||
} else {
|
||||
"https://cardanoscan.io/transaction/$hash"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Custom event type for Cardano payment requests (reverse-domain format) */
|
||||
const val EVENT_TYPE = "co.sulkta.payment.request"
|
||||
private const val LOVELACE_PER_ADA = 1_000_000.0
|
||||
|
||||
/**
|
||||
* Format lovelace amount as ADA string.
|
||||
*/
|
||||
fun formatAda(lovelace: Long): String {
|
||||
val ada = lovelace / LOVELACE_PER_ADA
|
||||
return if (ada == ada.toLong().toDouble()) {
|
||||
"${ada.toLong()} ADA"
|
||||
} else {
|
||||
val formatted = "%.6f".format(ada).trimEnd('0').trimEnd('.')
|
||||
"$formatted ADA"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a Cardano address for display (first 8 + last 6 chars).
|
||||
*/
|
||||
fun truncateAddress(address: String): String {
|
||||
return if (address.length > 18) {
|
||||
"${address.take(8)}...${address.takeLast(6)}"
|
||||
} else {
|
||||
address
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
features/wallet/impl/build.gradle.kts
Normal file
64
features/wallet/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import extension.setupDependencyInjection
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.wallet.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.features.wallet.api)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrix.impl)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.cryptography.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
// Cardano - using Koios backend (no API key required)
|
||||
implementation("com.bloxbean.cardano:cardano-client-lib:0.7.1")
|
||||
implementation("com.bloxbean.cardano:cardano-client-backend-koios:0.7.1")
|
||||
implementation("com.bloxbean.cardano:cardano-client-crypto:0.7.1")
|
||||
|
||||
// Biometric
|
||||
implementation(libs.androidx.biometric)
|
||||
|
||||
// JSON
|
||||
implementation(libs.serialization.json)
|
||||
// QR code generation
|
||||
implementation(libs.google.zxing)
|
||||
|
||||
// Coroutines
|
||||
implementation(libs.coroutines.core)
|
||||
|
||||
// Image loading for NFT thumbnails
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
// Testing
|
||||
testImplementation(projects.features.wallet.test)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.test.turbine)
|
||||
}
|
||||
10
features/wallet/impl/proguard-rules.pro
vendored
Normal file
10
features/wallet/impl/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Cardano client library uses reflection for CBOR serialization
|
||||
-keep class com.bloxbean.cardano.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@com.fasterxml.jackson.annotation.* *;
|
||||
}
|
||||
|
||||
# Keep the Cardano model classes
|
||||
-keep class com.bloxbean.cardano.client.api.model.** { *; }
|
||||
-keep class com.bloxbean.cardano.client.backend.model.** { *; }
|
||||
-keep class com.bloxbean.cardano.client.transaction.spec.** { *; }
|
||||
7
features/wallet/impl/src/main/AndroidManifest.xml
Normal file
7
features/wallet/impl/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Sulkta Coop.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<manifest />
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.wallet.api.WalletEntryPoint
|
||||
import io.element.android.features.wallet.impl.slash.ParsedPayCommand
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
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 DefaultWalletEntryPoint @Inject constructor() : WalletEntryPoint {
|
||||
class Builder(
|
||||
private val parentNode: Node,
|
||||
private val buildContext: BuildContext,
|
||||
private val callback: WalletEntryPoint.Callback,
|
||||
) : WalletEntryPoint.Builder {
|
||||
private var roomId: RoomId? = null
|
||||
private var recipientUserId: UserId? = null
|
||||
private var recipientAddress: String? = null
|
||||
private var amountLovelace: Long? = null
|
||||
private var parsedCommand: ParsedPayCommand? = null
|
||||
|
||||
override fun setRoomId(roomId: RoomId): Builder {
|
||||
this.roomId = roomId
|
||||
return this
|
||||
}
|
||||
|
||||
override fun setRecipientUserId(userId: UserId?): Builder {
|
||||
this.recipientUserId = userId
|
||||
return this
|
||||
}
|
||||
|
||||
override fun setRecipientAddress(address: String?): Builder {
|
||||
this.recipientAddress = address
|
||||
return this
|
||||
}
|
||||
|
||||
override fun setAmount(amount: String?): Builder {
|
||||
this.amountLovelace = amount?.toLongOrNull()?.let { value ->
|
||||
if (value < 1_000_000) {
|
||||
value * 1_000_000
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun setParsedCommand(command: ParsedPayCommand?): Builder {
|
||||
this.parsedCommand = command
|
||||
when (command) {
|
||||
is ParsedPayCommand.WithAddressRecipient -> {
|
||||
this.amountLovelace = command.amount
|
||||
this.recipientAddress = command.address
|
||||
}
|
||||
is ParsedPayCommand.WithMatrixRecipient -> {
|
||||
this.amountLovelace = command.amount
|
||||
this.recipientUserId = command.matrixUserId
|
||||
}
|
||||
is ParsedPayCommand.AmountOnly -> {
|
||||
this.amountLovelace = command.amount
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
val inputs = PaymentFlowNode.Inputs(
|
||||
roomId = requireNotNull(roomId) { "roomId must be set" },
|
||||
recipientUserId = recipientUserId,
|
||||
recipientAddress = recipientAddress,
|
||||
amountLovelace = amountLovelace,
|
||||
parsedCommand = parsedCommand,
|
||||
)
|
||||
return parentNode.createNode<PaymentFlowNode>(buildContext, listOf(inputs, callback))
|
||||
}
|
||||
}
|
||||
|
||||
override fun paymentFlowBuilder(
|
||||
parentNode: Node,
|
||||
buildContext: BuildContext,
|
||||
callback: WalletEntryPoint.Callback,
|
||||
): WalletEntryPoint.Builder {
|
||||
return Builder(parentNode, buildContext, callback)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.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 com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.wallet.api.WalletEntryPoint
|
||||
import io.element.android.features.wallet.impl.payment.PaymentConfirmationNode
|
||||
import io.element.android.features.wallet.impl.payment.PaymentEntryNode
|
||||
import io.element.android.features.wallet.impl.payment.PaymentProgressNode
|
||||
import io.element.android.features.wallet.impl.slash.Lovelace
|
||||
import io.element.android.features.wallet.impl.slash.ParsedPayCommand
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
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
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Main flow node for the payment flow.
|
||||
*
|
||||
* Navigation flow:
|
||||
* 1. Entry (amount/recipient input)
|
||||
* 2. Confirmation (tx details, biometric auth)
|
||||
* 3. Progress (submission + polling)
|
||||
*
|
||||
* Can skip to Confirmation if all details are pre-filled from /pay command.
|
||||
*/
|
||||
@ContributesNode(SessionScope::class)
|
||||
@AssistedInject
|
||||
class PaymentFlowNode(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BaseFlowNode<PaymentFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = initialElementFromInputs(plugins.filterIsInstance<Inputs>().first()),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
@Parcelize
|
||||
data class Inputs(
|
||||
val roomId: RoomId,
|
||||
val recipientUserId: UserId?,
|
||||
val recipientAddress: String?,
|
||||
val amountLovelace: Lovelace?,
|
||||
val parsedCommand: ParsedPayCommand?,
|
||||
) : NodeInputs, Parcelable
|
||||
|
||||
private val callback: WalletEntryPoint.Callback = callback()
|
||||
private val inputs: Inputs = plugins.filterIsInstance<Inputs>().first()
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data class Entry(
|
||||
val roomId: RoomId,
|
||||
val parsedCommand: ParsedPayCommand?,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Confirmation(
|
||||
val recipientAddress: String,
|
||||
val amountLovelace: Lovelace,
|
||||
val assetPolicyId: String?,
|
||||
val assetName: String?,
|
||||
val assetQuantity: Long?,
|
||||
val assetDisplayName: String?,
|
||||
) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Progress(
|
||||
val recipientAddress: String,
|
||||
val amountLovelace: Lovelace,
|
||||
val roomId: RoomId,
|
||||
val assetPolicyId: String?,
|
||||
val assetName: String?,
|
||||
val assetQuantity: Long?,
|
||||
val assetDisplayName: String?,
|
||||
) : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
is NavTarget.Entry -> {
|
||||
val nodeInputs = PaymentEntryNode.Inputs(
|
||||
roomId = navTarget.roomId,
|
||||
parsedCommand = navTarget.parsedCommand,
|
||||
)
|
||||
val nodeCallback = object : PaymentEntryNode.Callback {
|
||||
override fun onContinue(
|
||||
recipientAddress: String,
|
||||
amountLovelace: Long,
|
||||
assetPolicyId: String?,
|
||||
assetName: String?,
|
||||
assetQuantity: Long?,
|
||||
assetDisplayName: String?,
|
||||
) {
|
||||
backstack.push(NavTarget.Confirmation(
|
||||
recipientAddress = recipientAddress,
|
||||
amountLovelace = amountLovelace,
|
||||
assetPolicyId = assetPolicyId,
|
||||
assetName = assetName,
|
||||
assetQuantity = assetQuantity,
|
||||
assetDisplayName = assetDisplayName,
|
||||
))
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
callback.onPaymentCancelled()
|
||||
}
|
||||
|
||||
override fun onOpenWalletSettings() {
|
||||
// Cancel the payment flow and request wallet settings to be opened
|
||||
callback.onOpenWalletSettings()
|
||||
}
|
||||
}
|
||||
createNode<PaymentEntryNode>(buildContext, plugins = listOf(nodeInputs, nodeCallback))
|
||||
}
|
||||
|
||||
is NavTarget.Confirmation -> {
|
||||
val nodeInputs = PaymentConfirmationNode.Inputs(
|
||||
recipientAddress = navTarget.recipientAddress,
|
||||
amountLovelace = navTarget.amountLovelace,
|
||||
assetPolicyId = navTarget.assetPolicyId,
|
||||
assetName = navTarget.assetName,
|
||||
assetQuantity = navTarget.assetQuantity,
|
||||
assetDisplayName = navTarget.assetDisplayName,
|
||||
)
|
||||
val nodeCallback = object : PaymentConfirmationNode.Callback {
|
||||
override fun onConfirmed() {
|
||||
backstack.replace(NavTarget.Progress(
|
||||
recipientAddress = navTarget.recipientAddress,
|
||||
amountLovelace = navTarget.amountLovelace,
|
||||
roomId = inputs.roomId,
|
||||
assetPolicyId = navTarget.assetPolicyId,
|
||||
assetName = navTarget.assetName,
|
||||
assetQuantity = navTarget.assetQuantity,
|
||||
assetDisplayName = navTarget.assetDisplayName,
|
||||
))
|
||||
}
|
||||
|
||||
override fun onBack() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
createNode<PaymentConfirmationNode>(buildContext, plugins = listOf(nodeInputs, nodeCallback))
|
||||
}
|
||||
|
||||
is NavTarget.Progress -> {
|
||||
val nodeInputs = PaymentProgressNode.Inputs(
|
||||
recipientAddress = navTarget.recipientAddress,
|
||||
amountLovelace = navTarget.amountLovelace,
|
||||
roomId = navTarget.roomId,
|
||||
assetPolicyId = navTarget.assetPolicyId,
|
||||
assetName = navTarget.assetName,
|
||||
assetQuantity = navTarget.assetQuantity,
|
||||
assetDisplayName = navTarget.assetDisplayName,
|
||||
)
|
||||
val nodeCallback = object : PaymentProgressNode.Callback {
|
||||
override fun onPaymentComplete(txHash: String?) {
|
||||
if (txHash != null) {
|
||||
callback.onPaymentSent(txHash)
|
||||
} else {
|
||||
callback.onPaymentCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRetry() {
|
||||
// Go back to entry to retry
|
||||
backstack.pop()
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
createNode<PaymentProgressNode>(buildContext, plugins = listOf(nodeInputs, nodeCallback))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
BackstackView()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the initial screen based on the inputs.
|
||||
*
|
||||
* If we have all required data (amount + valid address), skip to confirmation.
|
||||
* Otherwise, show entry screen.
|
||||
*/
|
||||
private fun initialElementFromInputs(inputs: PaymentFlowNode.Inputs): PaymentFlowNode.NavTarget {
|
||||
// Check if we can skip to confirmation
|
||||
val parsedCommand = inputs.parsedCommand
|
||||
if (parsedCommand is ParsedPayCommand.WithAddressRecipient) {
|
||||
// Have both amount and address - go directly to confirmation (ADA only)
|
||||
return PaymentFlowNode.NavTarget.Confirmation(
|
||||
recipientAddress = parsedCommand.address,
|
||||
amountLovelace = parsedCommand.amount,
|
||||
assetPolicyId = null,
|
||||
assetName = null,
|
||||
assetQuantity = null,
|
||||
assetDisplayName = null,
|
||||
)
|
||||
}
|
||||
|
||||
// If we have a direct address and amount in inputs
|
||||
if (inputs.recipientAddress != null && inputs.amountLovelace != null) {
|
||||
return PaymentFlowNode.NavTarget.Confirmation(
|
||||
recipientAddress = inputs.recipientAddress,
|
||||
amountLovelace = inputs.amountLovelace,
|
||||
assetPolicyId = null,
|
||||
assetName = null,
|
||||
assetQuantity = null,
|
||||
assetDisplayName = null,
|
||||
)
|
||||
}
|
||||
|
||||
// Default: show entry screen
|
||||
return PaymentFlowNode.NavTarget.Entry(
|
||||
roomId = inputs.roomId,
|
||||
parsedCommand = inputs.parsedCommand,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.address
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.wallet.api.address.CardanoAddressService
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Implementation of [CardanoAddressService] that stores Cardano addresses
|
||||
* in Matrix account data for public discovery.
|
||||
*/
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultCardanoAddressService @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sessionStore: SessionStore,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : CardanoAddressService {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val httpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CardanoAddressData(
|
||||
val address: String
|
||||
)
|
||||
|
||||
override suspend fun publishAddress(address: String): Result<Unit> = withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
val sessionData = sessionStore.getSession(matrixClient.sessionId.value)
|
||||
?: throw IllegalStateException("No session found")
|
||||
|
||||
val userId = matrixClient.sessionId.value
|
||||
val url = "${sessionData.homeserverUrl}/_matrix/client/v3/user/$userId/account_data/${CardanoAddressService.ACCOUNT_DATA_TYPE}"
|
||||
|
||||
val body = json.encodeToString(CardanoAddressData(address))
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.put(body)
|
||||
.addHeader("Authorization", "Bearer ${sessionData.accessToken}")
|
||||
.build()
|
||||
|
||||
Timber.d("Publishing Cardano address to Matrix account data...")
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.body?.string() ?: "Unknown error"
|
||||
throw RuntimeException("Failed to publish address: ${response.code} - $errorBody")
|
||||
}
|
||||
|
||||
Timber.i("Successfully published Cardano address to Matrix account data")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun lookupAddress(userId: UserId): Result<String?> = withContext(dispatchers.io) {
|
||||
runCatching {
|
||||
val sessionData = sessionStore.getSession(matrixClient.sessionId.value)
|
||||
?: throw IllegalStateException("No session found")
|
||||
|
||||
val url = "${sessionData.homeserverUrl}/_matrix/client/v3/user/${userId.value}/account_data/${CardanoAddressService.ACCOUNT_DATA_TYPE}"
|
||||
|
||||
Timber.d("Looking up Cardano address for ${userId.value}...")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.addHeader("Authorization", "Bearer ${sessionData.accessToken}")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val responseBody = response.body?.string()
|
||||
if (responseBody != null) {
|
||||
val data = json.decodeFromString<CardanoAddressData>(responseBody)
|
||||
Timber.i("Found Cardano address for ${userId.value}: ${data.address.take(20)}...")
|
||||
data.address
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
404 -> {
|
||||
Timber.d("No Cardano address found for ${userId.value}")
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
val errorBody = response.body?.string() ?: "Unknown error"
|
||||
throw RuntimeException("Failed to lookup address: ${response.code} - $errorBody")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.backup
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.wallet.api.backup.WalletBackupService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.walletsecretstorage.WalletSecretStorage
|
||||
import io.element.android.libraries.matrix.api.walletsecretstorage.WalletSecretStorageException
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* [WalletBackupService] implementation that stores the Cardano wallet
|
||||
* seed phrase encrypted in Matrix account data via [WalletSecretStorage].
|
||||
*
|
||||
* We persist the mnemonic as a single space-separated string — the wire
|
||||
* form of the seed most BIP-39 tools already accept. The backup service
|
||||
* re-splits on read.
|
||||
*
|
||||
* History note: prior to 2026-04, this class used a low-level
|
||||
* `SecretStoreWrapper.putSecret` API that the Rust SDK removed between
|
||||
* 26.03.24 and 26.04.x. The new path uses Matrix account data under our
|
||||
* own namespace with our own AES-256-GCM envelope, so we no longer depend
|
||||
* on any SDK-internal secret-storage primitive.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class WalletBackupServiceImpl @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
) : WalletBackupService {
|
||||
|
||||
private val storage: WalletSecretStorage
|
||||
get() = matrixClient.walletSecretStorage
|
||||
|
||||
override suspend fun backupSeed(recoveryKey: String, mnemonic: List<String>): Result<Unit> {
|
||||
val seedString = mnemonic.joinToString(" ")
|
||||
return storage.putSeed(recoveryKey, seedString)
|
||||
.onSuccess { Timber.d("[WalletBackup] seed stored in account data") }
|
||||
.onFailure { Timber.w(it, "[WalletBackup] seed storage failed") }
|
||||
}
|
||||
|
||||
override suspend fun restoreSeed(recoveryKey: String): Result<List<String>?> {
|
||||
return storage.getSeed(recoveryKey).map { seedString ->
|
||||
seedString?.split(" ")?.takeIf { it.size in VALID_MNEMONIC_LENGTHS }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun hasBackup(recoveryKey: String): Result<Boolean> {
|
||||
// A successful decrypt into a valid-length mnemonic is our criterion.
|
||||
// Distinguishes "blob exists but wrong key" from "blob exists and opens".
|
||||
return restoreSeed(recoveryKey).map { it != null }
|
||||
}
|
||||
|
||||
override suspend fun hasBackupWithoutKey(): Result<Boolean> {
|
||||
return storage.hasSeedBackup()
|
||||
.onFailure { Timber.w(it, "[WalletBackup] hasSeedBackup probe failed") }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** BIP-39 permits these mnemonic word counts; anything else is corrupt. */
|
||||
val VALID_MNEMONIC_LENGTHS = setOf(12, 15, 18, 21, 24)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exceptions surfaced by wallet backup operations. Kept for compatibility
|
||||
* with call sites that pattern-match; the underlying storage failures now
|
||||
* come from [WalletSecretStorageException].
|
||||
*/
|
||||
sealed class WalletBackupException(message: String) : Exception(message) {
|
||||
class InvalidRecoveryKey : WalletBackupException("Recovery key is invalid or could not unlock the backup")
|
||||
class NoBackupFound : WalletBackupException("No wallet backup found")
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.biometric
|
||||
|
||||
import android.content.Context
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Helper class for biometric authentication at transaction signing.
|
||||
*
|
||||
* Uses BIOMETRIC_WEAK | DEVICE_CREDENTIAL to support:
|
||||
* - Fingerprint/face → biometric prompt
|
||||
* - PIN only → PIN prompt
|
||||
* - No auth set up → skips auth (doesn't block transactions)
|
||||
*/
|
||||
class BiometricAuthenticator @Inject constructor() {
|
||||
|
||||
sealed interface AuthResult {
|
||||
data object Success : AuthResult
|
||||
data class Error(val code: Int, val message: String) : AuthResult
|
||||
data object Cancelled : AuthResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any authentication method is available.
|
||||
* Returns true if biometric OR device credential (PIN/pattern/password) is available.
|
||||
*/
|
||||
fun canAuthenticate(context: Context): Boolean {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
val result = biometricManager.canAuthenticate(
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
return result == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device has any form of security (biometric, PIN, pattern, password).
|
||||
* If false, authentication will be skipped to avoid blocking transactions.
|
||||
*/
|
||||
fun isDeviceSecured(context: Context): Boolean {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
// Check both weak biometric and device credential
|
||||
val weakResult = biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
|
||||
val credentialResult = biometricManager.canAuthenticate(BiometricManager.Authenticators.DEVICE_CREDENTIAL)
|
||||
return weakResult == BiometricManager.BIOMETRIC_SUCCESS ||
|
||||
credentialResult == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate the user before a sensitive action (e.g., signing a transaction).
|
||||
*
|
||||
* - If device has biometric → shows biometric prompt
|
||||
* - If device has only PIN/pattern/password → shows device credential prompt
|
||||
* - If device has no security → returns Success immediately (don't block the tx)
|
||||
*/
|
||||
suspend fun authenticate(
|
||||
activity: FragmentActivity,
|
||||
title: String = "Confirm Payment",
|
||||
subtitle: String = "Authenticate to send ADA",
|
||||
): AuthResult {
|
||||
// If device has no security set up, allow through
|
||||
if (!isDeviceSecured(activity)) {
|
||||
return AuthResult.Success
|
||||
}
|
||||
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val executor = ContextCompat.getMainExecutor(activity)
|
||||
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(AuthResult.Success)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
if (continuation.isActive) {
|
||||
when (errorCode) {
|
||||
BiometricPrompt.ERROR_USER_CANCELED,
|
||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON,
|
||||
BiometricPrompt.ERROR_CANCELED -> {
|
||||
continuation.resume(AuthResult.Cancelled)
|
||||
}
|
||||
else -> {
|
||||
continuation.resume(AuthResult.Error(errorCode, errString.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
// User can retry, don't complete the continuation
|
||||
}
|
||||
}
|
||||
|
||||
val biometricPrompt = BiometricPrompt(activity, executor, callback)
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(title)
|
||||
.setSubtitle(subtitle)
|
||||
.setAllowedAuthenticators(
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
.build()
|
||||
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
biometricPrompt.cancelAuthentication()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.cardano
|
||||
|
||||
/**
|
||||
* Cardano network type.
|
||||
*/
|
||||
enum class CardanoNetwork {
|
||||
TESTNET,
|
||||
MAINNET,
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized network configuration for the Cardano wallet.
|
||||
*
|
||||
* To switch networks, change [NETWORK] to [CardanoNetwork.TESTNET].
|
||||
* All derived values (network ID, API URLs) will update automatically.
|
||||
*
|
||||
* **Current configuration: MAINNET**
|
||||
*/
|
||||
object CardanoNetworkConfig {
|
||||
/**
|
||||
* ⚠️ SWAP THIS VALUE TO SWITCH NETWORKS ⚠️
|
||||
*
|
||||
* Set to [CardanoNetwork.TESTNET] for development/testing.
|
||||
* Set to [CardanoNetwork.MAINNET] for production.
|
||||
*/
|
||||
val NETWORK: CardanoNetwork = CardanoNetwork.MAINNET
|
||||
|
||||
/**
|
||||
* Cardano network ID.
|
||||
* - Testnet (preprod): 0
|
||||
* - Mainnet: 1
|
||||
*/
|
||||
val NETWORK_ID: Int = when (NETWORK) {
|
||||
CardanoNetwork.TESTNET -> 0
|
||||
CardanoNetwork.MAINNET -> 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Koios API base URL for the configured network.
|
||||
* Koios is a decentralized API layer for Cardano requiring no API key.
|
||||
*
|
||||
* Rate limits: 100 req/10s for anonymous users.
|
||||
*/
|
||||
val KOIOS_BASE_URL: String = when (NETWORK) {
|
||||
CardanoNetwork.TESTNET -> "https://preprod.koios.rest/api/v1/"
|
||||
CardanoNetwork.MAINNET -> "https://api.koios.rest/api/v1/"
|
||||
}
|
||||
|
||||
/**
|
||||
* CardanoScan explorer URL for viewing transactions.
|
||||
*/
|
||||
val EXPLORER_BASE_URL: String = when (NETWORK) {
|
||||
CardanoNetwork.TESTNET -> "https://preprod.cardanoscan.io"
|
||||
CardanoNetwork.MAINNET -> "https://cardanoscan.io"
|
||||
}
|
||||
|
||||
/**
|
||||
* Bech32 address prefix for the configured network.
|
||||
*/
|
||||
val ADDRESS_PREFIX: String = when (NETWORK) {
|
||||
CardanoNetwork.TESTNET -> "addr_test1"
|
||||
CardanoNetwork.MAINNET -> "addr1"
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable network name.
|
||||
*/
|
||||
val NETWORK_NAME: String = when (NETWORK) {
|
||||
CardanoNetwork.TESTNET -> "Preprod Testnet"
|
||||
CardanoNetwork.MAINNET -> "Mainnet"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Network instance for cardano-client-lib.
|
||||
*/
|
||||
fun getNetwork(): com.bloxbean.cardano.client.common.model.Network = when (NETWORK) {
|
||||
CardanoNetwork.TESTNET -> com.bloxbean.cardano.client.common.model.Networks.preprod()
|
||||
CardanoNetwork.MAINNET -> com.bloxbean.cardano.client.common.model.Networks.mainnet()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.cardano
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.wallet.api.WalletState
|
||||
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import timber.log.Timber
|
||||
|
||||
interface CardanoWalletManager {
|
||||
val walletState: StateFlow<WalletState>
|
||||
suspend fun initialize(sessionId: SessionId)
|
||||
suspend fun getAddress(sessionId: SessionId): Result<String>
|
||||
suspend fun getStakeAddress(sessionId: SessionId): Result<String>
|
||||
/** Called by session-scoped components after fetching balance from chain. */
|
||||
suspend fun refreshBalance(sessionId: SessionId, balanceLovelace: Long)
|
||||
suspend fun getMnemonic(sessionId: SessionId): Result<List<String>>
|
||||
fun clearState()
|
||||
}
|
||||
|
||||
/**
|
||||
* App-scoped wallet manager. Handles key derivation and state only.
|
||||
* Balance refresh is driven by session-scoped components that have access to CardanoClient.
|
||||
*/
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultCardanoWalletManager @Inject constructor(
|
||||
private val keyStorage: CardanoKeyStorage,
|
||||
) : CardanoWalletManager {
|
||||
|
||||
private val _walletState = MutableStateFlow(WalletState.Initial)
|
||||
override val walletState: StateFlow<WalletState> = _walletState
|
||||
|
||||
override suspend fun initialize(sessionId: SessionId) {
|
||||
_walletState.value = WalletState.Initial.copy(isLoading = true)
|
||||
try {
|
||||
val hasWallet = keyStorage.hasWallet(sessionId)
|
||||
if (hasWallet) {
|
||||
val address = keyStorage.getBaseAddress(sessionId).getOrNull()
|
||||
_walletState.value = WalletState(
|
||||
isLoading = false,
|
||||
hasWallet = true,
|
||||
address = address,
|
||||
balanceLovelace = 0L,
|
||||
balanceAda = "0",
|
||||
error = null,
|
||||
)
|
||||
} else {
|
||||
_walletState.value = WalletState(
|
||||
isLoading = false,
|
||||
hasWallet = false,
|
||||
address = null,
|
||||
balanceLovelace = null,
|
||||
balanceAda = null,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to initialize wallet")
|
||||
_walletState.value = WalletState(
|
||||
isLoading = false,
|
||||
hasWallet = false,
|
||||
address = null,
|
||||
balanceLovelace = null,
|
||||
balanceAda = null,
|
||||
error = e.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAddress(sessionId: SessionId): Result<String> =
|
||||
keyStorage.getBaseAddress(sessionId)
|
||||
|
||||
override suspend fun getStakeAddress(sessionId: SessionId): Result<String> =
|
||||
keyStorage.getStakeAddress(sessionId)
|
||||
|
||||
override suspend fun refreshBalance(sessionId: SessionId, balanceLovelace: Long) {
|
||||
val current = _walletState.value
|
||||
if (current.hasWallet) {
|
||||
val ada = "%.6f".format(balanceLovelace / 1_000_000.0)
|
||||
_walletState.value = current.copy(
|
||||
balanceLovelace = balanceLovelace,
|
||||
balanceAda = ada,
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMnemonic(sessionId: SessionId): Result<List<String>> = keyStorage.getMnemonic(sessionId)
|
||||
override fun clearState() {
|
||||
_walletState.value = WalletState.Initial
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.cardano
|
||||
|
||||
import com.bloxbean.cardano.client.account.Account
|
||||
import com.bloxbean.cardano.client.api.model.Amount
|
||||
import com.bloxbean.cardano.client.backend.api.BackendService
|
||||
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService
|
||||
import com.bloxbean.cardano.client.function.helper.SignerProviders
|
||||
import com.bloxbean.cardano.client.quicktx.QuickTxBuilder
|
||||
import com.bloxbean.cardano.client.quicktx.Tx
|
||||
import com.bloxbean.cardano.client.transaction.util.TransactionUtil
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.wallet.api.CardanoClient
|
||||
import io.element.android.features.wallet.api.CardanoException
|
||||
import io.element.android.features.wallet.api.PaymentRequest
|
||||
import io.element.android.features.wallet.api.SignedTransaction
|
||||
import io.element.android.features.wallet.api.TransactionBuilder
|
||||
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Default implementation of [TransactionBuilder] using cardano-client-lib.
|
||||
*/
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultTransactionBuilder @Inject constructor(
|
||||
private val cardanoClient: CardanoClient,
|
||||
private val keyStorage: CardanoKeyStorage,
|
||||
) : TransactionBuilder {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "TransactionBuilder"
|
||||
const val MIN_UTXO_LOVELACE = 1_000_000L
|
||||
// Minimum ADA to include with token sends (protocol requirement)
|
||||
const val MIN_TOKEN_UTXO_LOVELACE = 1_500_000L
|
||||
private const val ROUGH_FEE_ESTIMATE = 200_000L
|
||||
}
|
||||
|
||||
private val backendService: BackendService by lazy {
|
||||
Timber.tag(TAG).d("Initializing Koios backend for tx building")
|
||||
KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
|
||||
}
|
||||
|
||||
override suspend fun buildAndSign(request: PaymentRequest): Result<SignedTransaction> = withContext(Dispatchers.IO) {
|
||||
Timber.tag(TAG).d("Building transaction: ${request.amountLovelace} lovelace to ${request.toAddress.take(20)}...")
|
||||
if (request.hasAsset) {
|
||||
Timber.tag(TAG).d("Including asset: ${request.assetPolicyId?.take(16)}... qty=${request.assetQuantity}")
|
||||
}
|
||||
|
||||
runCatching {
|
||||
validateAddress(request.fromAddress, "sender")
|
||||
validateAddress(request.toAddress, "recipient")
|
||||
|
||||
// For token sends, enforce minimum ADA
|
||||
val effectiveLovelace = if (request.hasAsset) {
|
||||
maxOf(request.amountLovelace, MIN_TOKEN_UTXO_LOVELACE)
|
||||
} else {
|
||||
request.amountLovelace
|
||||
}
|
||||
|
||||
if (!request.hasAsset && effectiveLovelace < MIN_UTXO_LOVELACE) {
|
||||
throw CardanoException.ApiException(
|
||||
message = "Amount too small: minimum is 1 ADA (1,000,000 lovelace)",
|
||||
response = "MIN_UTXO_VIOLATION"
|
||||
)
|
||||
}
|
||||
|
||||
val utxos = cardanoClient.getUtxos(request.fromAddress).getOrThrow()
|
||||
if (utxos.isEmpty()) {
|
||||
throw CardanoException.InsufficientFundsException(
|
||||
required = effectiveLovelace,
|
||||
available = 0L
|
||||
)
|
||||
}
|
||||
|
||||
val totalAvailable = utxos.sumOf { it.amount }
|
||||
val estimatedRequired = effectiveLovelace + ROUGH_FEE_ESTIMATE
|
||||
|
||||
if (totalAvailable < estimatedRequired) {
|
||||
throw CardanoException.InsufficientFundsException(
|
||||
required = estimatedRequired,
|
||||
available = totalAvailable
|
||||
)
|
||||
}
|
||||
|
||||
// Validate token balance if sending tokens
|
||||
if (request.hasAsset) {
|
||||
val availableTokens = utxos.flatMap { it.assets }
|
||||
.filter { it.policyId == request.assetPolicyId && it.assetName == request.assetName }
|
||||
.sumOf { it.quantity }
|
||||
|
||||
if (availableTokens < (request.assetQuantity ?: 0L)) {
|
||||
throw CardanoException.ApiException(
|
||||
message = "Insufficient token balance: have $availableTokens, need ${request.assetQuantity}",
|
||||
response = "INSUFFICIENT_TOKEN_BALANCE"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag(TAG).d("UTXOs: ${utxos.size} totaling $totalAvailable lovelace")
|
||||
|
||||
val mnemonicWords = keyStorage.getMnemonic(request.sessionId).getOrThrow()
|
||||
val mnemonicString = mnemonicWords.joinToString(" ")
|
||||
|
||||
try {
|
||||
val signedTx = buildTransaction(
|
||||
senderAddress = request.fromAddress,
|
||||
recipientAddress = request.toAddress,
|
||||
amountLovelace = effectiveLovelace,
|
||||
mnemonic = mnemonicString,
|
||||
assetPolicyId = request.assetPolicyId,
|
||||
assetName = request.assetName,
|
||||
assetQuantity = request.assetQuantity,
|
||||
)
|
||||
|
||||
Timber.tag(TAG).i("Transaction built: ${signedTx.txHash}, fee: ${signedTx.fee} lovelace")
|
||||
signedTx
|
||||
} finally {
|
||||
Timber.tag(TAG).d("Transaction building complete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTransaction(
|
||||
senderAddress: String,
|
||||
recipientAddress: String,
|
||||
amountLovelace: Long,
|
||||
mnemonic: String,
|
||||
assetPolicyId: String? = null,
|
||||
assetName: String? = null,
|
||||
assetQuantity: Long? = null,
|
||||
): SignedTransaction {
|
||||
val account = Account(CardanoNetworkConfig.getNetwork(), mnemonic)
|
||||
|
||||
// Build the list of amounts to send
|
||||
val amounts = mutableListOf<Amount>()
|
||||
|
||||
// Always include ADA
|
||||
amounts.add(Amount.lovelace(BigInteger.valueOf(amountLovelace)))
|
||||
|
||||
// Add native asset if specified
|
||||
if (assetPolicyId != null && assetName != null && assetQuantity != null && assetQuantity > 0) {
|
||||
amounts.add(Amount.asset(assetPolicyId, assetName, BigInteger.valueOf(assetQuantity)))
|
||||
}
|
||||
|
||||
val tx = Tx()
|
||||
.payToAddress(recipientAddress, amounts)
|
||||
.from(senderAddress)
|
||||
|
||||
val quickTxBuilder = QuickTxBuilder(backendService)
|
||||
|
||||
val signedTx = quickTxBuilder
|
||||
.compose(tx)
|
||||
.withSigner(SignerProviders.signerFrom(account))
|
||||
.buildAndSign()
|
||||
|
||||
val txHash = TransactionUtil.getTxHash(signedTx)
|
||||
val txCbor = signedTx.serializeToHex()
|
||||
val fee = signedTx.body.fee.toLong()
|
||||
|
||||
return SignedTransaction(
|
||||
txCbor = txCbor,
|
||||
txHash = txHash,
|
||||
fee = fee,
|
||||
actualAmount = amountLovelace,
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateAddress(address: String, role: String) {
|
||||
val expectedPrefix = CardanoNetworkConfig.ADDRESS_PREFIX
|
||||
|
||||
if (!address.startsWith(expectedPrefix)) {
|
||||
throw CardanoException.InvalidAddressException(address)
|
||||
}
|
||||
|
||||
if (address.length < 50) {
|
||||
throw CardanoException.InvalidAddressException(address)
|
||||
}
|
||||
|
||||
Timber.tag(TAG).d("$role address validated: ${address.take(20)}...")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,658 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.cardano
|
||||
|
||||
import com.bloxbean.cardano.client.backend.api.BackendService
|
||||
import com.bloxbean.cardano.client.backend.koios.KoiosBackendService
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.wallet.api.CardanoClient
|
||||
import io.element.android.features.wallet.api.CardanoException
|
||||
import io.element.android.features.wallet.api.NativeAsset
|
||||
import io.element.android.features.wallet.api.NftMetadata
|
||||
import io.element.android.features.wallet.api.ProtocolParameters
|
||||
import io.element.android.features.wallet.api.TxStatus
|
||||
import io.element.android.features.wallet.api.TxSummary
|
||||
import io.element.android.features.wallet.api.Utxo
|
||||
import io.element.android.features.wallet.api.UtxoAsset
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Cardano blockchain client using the Koios public API.
|
||||
* Uses direct HTTP calls for reliable API compatibility.
|
||||
*/
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class KoiosCardanoClient @Inject constructor() : CardanoClient {
|
||||
companion object {
|
||||
private const val TAG = "KoiosCardanoClient"
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val INITIAL_BACKOFF_MS = 1000L
|
||||
private const val MAX_BACKOFF_MS = 10000L
|
||||
private const val MIN_REQUEST_INTERVAL_MS = 100L
|
||||
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
|
||||
|
||||
// ADA Handle policy ID (same for mainnet and testnet)
|
||||
private const val ADA_HANDLE_POLICY_ID = "f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a"
|
||||
private const val HANDLE_CACHE_TTL_MS = 60 * 60 * 1000L // 1 hour
|
||||
}
|
||||
|
||||
private val httpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
// Fallback to cardano-client-lib for protocol params and tx submission
|
||||
private val backendService: BackendService by lazy {
|
||||
Timber.tag(TAG).d("Initializing Koios backend for ${CardanoNetworkConfig.NETWORK_NAME}")
|
||||
KoiosBackendService(CardanoNetworkConfig.KOIOS_BASE_URL)
|
||||
}
|
||||
|
||||
private val rateLimitMutex = Mutex()
|
||||
private var lastRequestTimeMs = 0L
|
||||
|
||||
// Handle resolution cache
|
||||
private data class CachedHandle(val address: String?, val timestamp: Long)
|
||||
private val handleCache = mutableMapOf<String, CachedHandle>()
|
||||
|
||||
// NFT metadata cache
|
||||
private val nftMetadataCache = mutableMapOf<String, NftMetadata?>()
|
||||
|
||||
override suspend fun getBalance(address: String): Result<Long> =
|
||||
withRetry("getBalance($address)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
// Use direct HTTP POST to Koios address_info endpoint
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}address_info"
|
||||
val body = JSONObject().apply {
|
||||
put("_addresses", JSONArray().put(address))
|
||||
}.toString()
|
||||
|
||||
Timber.tag(TAG).d("getBalance calling: $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
Timber.tag(TAG).d("getBalance response: code=${response.code}, body=${responseBody.take(500)}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
if (jsonArray.length() == 0) {
|
||||
// No data means unfunded address
|
||||
Timber.tag(TAG).d("Address not found in response, returning 0")
|
||||
return@withContext Result.success(0L)
|
||||
}
|
||||
|
||||
val addressInfo = jsonArray.getJSONObject(0)
|
||||
val balance = addressInfo.optString("balance", "0").toLongOrNull() ?: 0L
|
||||
Timber.tag(TAG).d("getBalance result: $balance lovelace")
|
||||
Result.success(balance)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getUtxos(address: String): Result<List<Utxo>> =
|
||||
withRetry("getUtxos($address)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
// Use direct HTTP POST to Koios address_info endpoint (includes utxo_set)
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}address_info"
|
||||
val body = JSONObject().apply {
|
||||
put("_addresses", JSONArray().put(address))
|
||||
}.toString()
|
||||
|
||||
Timber.tag(TAG).d("getUtxos calling: $url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
if (jsonArray.length() == 0) {
|
||||
return@withContext Result.success(emptyList())
|
||||
}
|
||||
|
||||
val addressInfo = jsonArray.getJSONObject(0)
|
||||
val utxoSet = addressInfo.optJSONArray("utxo_set") ?: JSONArray()
|
||||
|
||||
val utxos = (0 until utxoSet.length()).map { i ->
|
||||
val utxoJson = utxoSet.getJSONObject(i)
|
||||
val lovelace = utxoJson.optString("value", "0").toLongOrNull() ?: 0L
|
||||
|
||||
// Parse native assets in this UTXO
|
||||
val assetList = utxoJson.optJSONArray("asset_list") ?: JSONArray()
|
||||
val assets = (0 until assetList.length()).map { j ->
|
||||
val asset = assetList.getJSONObject(j)
|
||||
UtxoAsset(
|
||||
policyId = asset.getString("policy_id"),
|
||||
assetName = asset.optString("asset_name", ""),
|
||||
quantity = asset.optString("quantity", "0").toLongOrNull() ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
Utxo(
|
||||
txHash = utxoJson.getString("tx_hash"),
|
||||
outputIndex = utxoJson.getInt("tx_index"),
|
||||
amount = lovelace,
|
||||
address = address,
|
||||
assets = assets,
|
||||
)
|
||||
}
|
||||
|
||||
val totalAssets = utxos.flatMap { it.assets }.sumOf { it.quantity }
|
||||
Timber.tag(TAG).d("getUtxos result: ${utxos.size} UTXOs, total=${utxos.sumOf { it.amount }}, assets=$totalAssets")
|
||||
Result.success(utxos)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun submitTx(signedTxCbor: String): Result<String> =
|
||||
withRetry("submitTx") {
|
||||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val txBytes = try {
|
||||
signedTxCbor.hexToByteArray()
|
||||
} catch (e: Exception) {
|
||||
return@withContext Result.failure(
|
||||
CardanoException.SubmissionFailedException(
|
||||
message = "Invalid CBOR hex string",
|
||||
cause = e,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val result = backendService.transactionService.submitTransaction(txBytes)
|
||||
if (result.isSuccessful) {
|
||||
Timber.tag(TAG).i("Transaction submitted: ${result.value}")
|
||||
Result.success(result.value)
|
||||
} else {
|
||||
Timber.tag(TAG).e("Transaction submission failed: ${result.response}")
|
||||
Result.failure(
|
||||
CardanoException.SubmissionFailedException(
|
||||
message = "Transaction submission failed",
|
||||
errorCode = result.response,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getTxStatus(txHash: String): Result<TxStatus> =
|
||||
withRetry("getTxStatus($txHash)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val result = backendService.transactionService.getTransaction(txHash)
|
||||
if (result.isSuccessful) {
|
||||
Result.success(TxStatus.CONFIRMED)
|
||||
} else {
|
||||
val response = result.response ?: ""
|
||||
when {
|
||||
response.contains("404") || response.contains("not found", ignoreCase = true) -> {
|
||||
Result.success(TxStatus.PENDING)
|
||||
}
|
||||
else -> {
|
||||
Result.failure(parseError(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getProtocolParameters(): Result<ProtocolParameters> =
|
||||
withRetry("getProtocolParameters") {
|
||||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val result = backendService.epochService.protocolParameters
|
||||
if (result.isSuccessful) {
|
||||
val params = result.value
|
||||
Result.success(
|
||||
ProtocolParameters(
|
||||
minFeeA = params.minFeeA?.toLong() ?: 44L,
|
||||
minFeeB = params.minFeeB?.toLong() ?: 155381L,
|
||||
maxTxSize = params.maxTxSize ?: 16384,
|
||||
utxoCostPerByte = params.coinsPerUtxoSize?.toLong() ?: 4310L,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Result.failure(parseError(result.response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAddressAssets(address: String): Result<List<NativeAsset>> =
|
||||
withRetry("getAddressAssets($address)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}address_info"
|
||||
val body = JSONObject().apply {
|
||||
put("_addresses", JSONArray().put(address))
|
||||
}.toString()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
if (jsonArray.length() == 0) {
|
||||
return@withContext Result.success(emptyList())
|
||||
}
|
||||
|
||||
val addressInfo = jsonArray.getJSONObject(0)
|
||||
val utxoSet = addressInfo.optJSONArray("utxo_set") ?: JSONArray()
|
||||
|
||||
val assetMap = mutableMapOf<String, Long>()
|
||||
|
||||
for (i in 0 until utxoSet.length()) {
|
||||
val utxoJson = utxoSet.getJSONObject(i)
|
||||
val assetList = utxoJson.optJSONArray("asset_list") ?: continue
|
||||
|
||||
for (j in 0 until assetList.length()) {
|
||||
val asset = assetList.getJSONObject(j)
|
||||
val policyId = asset.getString("policy_id")
|
||||
val assetName = asset.optString("asset_name", "")
|
||||
val quantity = asset.optString("quantity", "0").toLongOrNull() ?: 0L
|
||||
val key = "$policyId$assetName"
|
||||
assetMap[key] = (assetMap[key] ?: 0L) + quantity
|
||||
}
|
||||
}
|
||||
|
||||
val assets = assetMap.map { (key, quantity) ->
|
||||
val policyId = key.take(56)
|
||||
val assetNameHex = key.drop(56)
|
||||
// Mark as potential NFT if quantity is 1
|
||||
NativeAsset(
|
||||
policyId = policyId,
|
||||
assetName = assetNameHex,
|
||||
quantity = quantity,
|
||||
displayName = null,
|
||||
fingerprint = null,
|
||||
isNft = quantity == 1L,
|
||||
)
|
||||
}
|
||||
|
||||
Result.success(assets)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getAddressTransactions(address: String, limit: Int): Result<List<TxSummary>> =
|
||||
withRetry("getAddressTransactions($address)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
throttleRequest()
|
||||
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}address_txs"
|
||||
val body = JSONObject().apply {
|
||||
put("_addresses", JSONArray().put(address))
|
||||
}.toString()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
val txs = (0 until minOf(jsonArray.length(), limit)).map { i ->
|
||||
val txJson = jsonArray.getJSONObject(i)
|
||||
TxSummary(
|
||||
txHash = txJson.getString("tx_hash"),
|
||||
blockTime = txJson.optLong("block_time", 0L),
|
||||
totalOutput = 0L,
|
||||
fee = 0L,
|
||||
direction = TxSummary.Direction.RECEIVED,
|
||||
)
|
||||
}
|
||||
Result.success(txs)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun resolveHandle(handle: String): Result<String?> =
|
||||
withRetry("resolveHandle($handle)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Normalize handle to lowercase
|
||||
val normalizedHandle = handle.lowercase().trim()
|
||||
|
||||
// Check cache first
|
||||
val cached = handleCache[normalizedHandle]
|
||||
if (cached != null && System.currentTimeMillis() - cached.timestamp < HANDLE_CACHE_TTL_MS) {
|
||||
Timber.tag(TAG).d("resolveHandle: cache hit for $normalizedHandle -> ${cached.address}")
|
||||
return@withContext Result.success(cached.address)
|
||||
}
|
||||
|
||||
throttleRequest()
|
||||
|
||||
// Convert handle to hex (ASCII bytes to hex string)
|
||||
val handleHex = normalizedHandle.toByteArray(Charsets.US_ASCII)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}asset_addresses"
|
||||
val body = JSONObject().apply {
|
||||
put("_asset_policy", ADA_HANDLE_POLICY_ID)
|
||||
put("_asset_name", handleHex)
|
||||
}.toString()
|
||||
|
||||
Timber.tag(TAG).d("resolveHandle: $normalizedHandle -> hex=$handleHex, url=$url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
Timber.tag(TAG).d("resolveHandle response: code=${response.code}, body=${responseBody.take(500)}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
val address = if (jsonArray.length() > 0) {
|
||||
jsonArray.getJSONObject(0).getString("payment_address")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
handleCache[normalizedHandle] = CachedHandle(address, System.currentTimeMillis())
|
||||
|
||||
Timber.tag(TAG).d("resolveHandle: $normalizedHandle -> $address")
|
||||
Result.success(address)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getNftMetadata(policyId: String, assetName: String): Result<NftMetadata?> =
|
||||
withRetry("getNftMetadata($policyId, $assetName)") {
|
||||
withContext(Dispatchers.IO) {
|
||||
val cacheKey = "$policyId$assetName"
|
||||
|
||||
// Check cache first
|
||||
if (nftMetadataCache.containsKey(cacheKey)) {
|
||||
return@withContext Result.success(nftMetadataCache[cacheKey])
|
||||
}
|
||||
|
||||
throttleRequest()
|
||||
|
||||
val url = "${CardanoNetworkConfig.KOIOS_BASE_URL}asset_info"
|
||||
val body = JSONObject().apply {
|
||||
put("_asset_list", JSONArray().put(JSONArray().apply {
|
||||
put(policyId)
|
||||
put(assetName)
|
||||
}))
|
||||
}.toString()
|
||||
|
||||
Timber.tag(TAG).d("getNftMetadata calling: $url with policy=$policyId, asset=$assetName")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toRequestBody(JSON_MEDIA_TYPE))
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: ""
|
||||
|
||||
Timber.tag(TAG).d("getNftMetadata response: code=${response.code}, body=${responseBody.take(1000)}")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return@withContext Result.failure(parseHttpError(response.code, responseBody))
|
||||
}
|
||||
|
||||
val jsonArray = JSONArray(responseBody)
|
||||
if (jsonArray.length() == 0) {
|
||||
nftMetadataCache[cacheKey] = null
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
|
||||
val assetInfo = jsonArray.getJSONObject(0)
|
||||
|
||||
// Parse CIP-25 onchain_metadata
|
||||
val metadata = try {
|
||||
parseCip25Metadata(assetInfo)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).w(e, "Failed to parse CIP-25 metadata")
|
||||
null
|
||||
}
|
||||
|
||||
nftMetadataCache[cacheKey] = metadata
|
||||
Result.success(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CIP-25 metadata from Koios asset_info response.
|
||||
*/
|
||||
private fun parseCip25Metadata(assetInfo: JSONObject): NftMetadata? {
|
||||
// Check for onchain_metadata (CIP-25)
|
||||
val onchainMetadata = assetInfo.optJSONObject("onchain_metadata") ?: return null
|
||||
|
||||
// Get asset name for lookup (decoded)
|
||||
val assetNameHex = assetInfo.optString("asset_name", "")
|
||||
val assetNameDecoded = assetInfo.optString("asset_name_ascii", "")
|
||||
|
||||
// Extract name - could be in various places
|
||||
val name = onchainMetadata.optString("name")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?: assetNameDecoded.takeIf { it.isNotEmpty() }
|
||||
?: assetNameHex
|
||||
|
||||
// Extract image - handle both string and array formats
|
||||
val imageUrl = extractImageUrl(onchainMetadata)
|
||||
|
||||
// Extract description
|
||||
val description = onchainMetadata.optString("description")
|
||||
.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Build raw metadata map
|
||||
val rawMetadata = mutableMapOf<String, Any>()
|
||||
onchainMetadata.keys().forEach { key ->
|
||||
val value = onchainMetadata.get(key)
|
||||
if (value != null && value != JSONObject.NULL) {
|
||||
rawMetadata[key] = convertJsonValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
return NftMetadata(
|
||||
name = name,
|
||||
image = imageUrl,
|
||||
description = description,
|
||||
rawMetadata = rawMetadata,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image URL from CIP-25 metadata, handling various formats.
|
||||
*/
|
||||
private fun extractImageUrl(metadata: JSONObject): String? {
|
||||
return try {
|
||||
when (val imageValue = metadata.opt("image")) {
|
||||
is String -> NftMetadata.resolveImageUrl(imageValue)
|
||||
is JSONArray -> {
|
||||
// Some NFTs split the URL across multiple array elements
|
||||
val parts = (0 until imageValue.length()).mapNotNull {
|
||||
imageValue.optString(it).takeIf { s -> s.isNotEmpty() }
|
||||
}
|
||||
NftMetadata.joinImageParts(parts)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).w(e, "Failed to extract image URL")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert JSON value to Kotlin type for raw metadata map.
|
||||
*/
|
||||
private fun convertJsonValue(value: Any): Any {
|
||||
return when (value) {
|
||||
is JSONObject -> {
|
||||
val map = mutableMapOf<String, Any>()
|
||||
value.keys().forEach { key ->
|
||||
val v = value.get(key)
|
||||
if (v != null && v != JSONObject.NULL) {
|
||||
map[key] = convertJsonValue(v)
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
is JSONArray -> {
|
||||
(0 until value.length()).mapNotNull { i ->
|
||||
val v = value.opt(i)
|
||||
if (v != null && v != JSONObject.NULL) convertJsonValue(v) else null
|
||||
}
|
||||
}
|
||||
else -> value
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> withRetry(
|
||||
operation: String,
|
||||
block: suspend () -> Result<T>,
|
||||
): Result<T> {
|
||||
var lastException: Throwable? = null
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
|
||||
repeat(MAX_RETRIES) { attempt ->
|
||||
Timber.tag(TAG).d("$operation: attempt ${attempt + 1}/$MAX_RETRIES")
|
||||
|
||||
val result = try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).w(e, "$operation: exception on attempt ${attempt + 1}")
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
return result
|
||||
}
|
||||
|
||||
val exception = result.exceptionOrNull() ?: Exception("Unknown error")
|
||||
lastException = exception
|
||||
|
||||
val shouldRetry = when (exception) {
|
||||
is CardanoException.RateLimitException -> {
|
||||
backoffMs = exception.retryAfterMs ?: (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
|
||||
true
|
||||
}
|
||||
is CardanoException.NetworkException -> {
|
||||
exception.statusCode == null || exception.statusCode in 500..599
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!shouldRetry || attempt == MAX_RETRIES - 1) {
|
||||
Timber.tag(TAG).e("$operation: giving up after ${attempt + 1} attempts")
|
||||
return result
|
||||
}
|
||||
|
||||
Timber.tag(TAG).d("$operation: retrying in ${backoffMs}ms")
|
||||
delay(backoffMs)
|
||||
backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
|
||||
}
|
||||
|
||||
return Result.failure(lastException ?: Exception("Max retries exceeded"))
|
||||
}
|
||||
|
||||
private suspend fun throttleRequest() {
|
||||
rateLimitMutex.withLock {
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - lastRequestTimeMs
|
||||
if (elapsed < MIN_REQUEST_INTERVAL_MS) {
|
||||
delay(MIN_REQUEST_INTERVAL_MS - elapsed)
|
||||
}
|
||||
lastRequestTimeMs = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseHttpError(code: Int, response: String): CardanoException {
|
||||
return when (code) {
|
||||
429 -> CardanoException.RateLimitException()
|
||||
404 -> CardanoException.ApiException("Resource not found", response)
|
||||
in 500..599 -> CardanoException.NetworkException("Server error", statusCode = code)
|
||||
else -> CardanoException.ApiException("HTTP $code: $response", response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseError(response: String?): CardanoException {
|
||||
if (response == null) {
|
||||
return CardanoException.NetworkException("No response from server")
|
||||
}
|
||||
|
||||
return when {
|
||||
response.contains("429") -> CardanoException.RateLimitException()
|
||||
response.contains("404") -> CardanoException.ApiException("Resource not found", response)
|
||||
response.contains("500") || response.contains("502") || response.contains("503") -> {
|
||||
CardanoException.NetworkException("Server error", statusCode = 500)
|
||||
}
|
||||
else -> CardanoException.ApiException("API error: $response", response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
require(length % 2 == 0) { "Hex string must have even length" }
|
||||
return chunked(2)
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.cardano
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.wallet.api.CardanoClient
|
||||
import io.element.android.features.wallet.api.PaymentStatusPoller
|
||||
import io.element.android.features.wallet.api.TxStatus
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import timber.log.Timber
|
||||
import dev.zacsweers.metro.Inject
|
||||
|
||||
/**
|
||||
* Default implementation of [PaymentStatusPoller].
|
||||
*/
|
||||
@SingleIn(SessionScope::class)
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultPaymentStatusPoller @Inject constructor(
|
||||
private val cardanoClient: CardanoClient,
|
||||
) : PaymentStatusPoller {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PaymentStatusPoller"
|
||||
|
||||
/** Interval between polls in milliseconds */
|
||||
private const val POLL_INTERVAL_MS = 10_000L // 10 seconds
|
||||
|
||||
/** Maximum number of polling attempts */
|
||||
private const val MAX_ATTEMPTS = 60 // ~10 minutes total
|
||||
|
||||
/** Initial delay before first poll (give network time to propagate) */
|
||||
private const val INITIAL_DELAY_MS = 5_000L // 5 seconds
|
||||
}
|
||||
|
||||
override fun pollUntilConfirmed(txHash: String): Flow<TxStatus> = flow {
|
||||
Timber.tag(TAG).d("Starting to poll for tx: $txHash")
|
||||
|
||||
// Emit initial PENDING status
|
||||
emit(TxStatus.PENDING)
|
||||
|
||||
// Wait a bit before first poll (transaction needs time to propagate)
|
||||
delay(INITIAL_DELAY_MS)
|
||||
|
||||
var attempts = 0
|
||||
var lastStatus = TxStatus.PENDING
|
||||
|
||||
while (attempts < MAX_ATTEMPTS && lastStatus == TxStatus.PENDING) {
|
||||
attempts++
|
||||
Timber.tag(TAG).d("Poll attempt $attempts/$MAX_ATTEMPTS for tx: $txHash")
|
||||
|
||||
try {
|
||||
val result = cardanoClient.getTxStatus(txHash)
|
||||
|
||||
result.fold(
|
||||
onSuccess = { status ->
|
||||
if (status != lastStatus) {
|
||||
Timber.tag(TAG).i("Tx $txHash status changed: $lastStatus -> $status")
|
||||
lastStatus = status
|
||||
emit(status)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
Timber.tag(TAG).w(error, "Error polling tx $txHash (attempt $attempts)")
|
||||
// Don't emit FAILED on transient errors, continue polling
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TAG).e(e, "Exception polling tx $txHash")
|
||||
// Continue polling on error
|
||||
}
|
||||
|
||||
// Don't wait if we're done polling
|
||||
if (lastStatus == TxStatus.PENDING && attempts < MAX_ATTEMPTS) {
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhausted attempts without confirmation, mark as potentially failed
|
||||
if (lastStatus == TxStatus.PENDING) {
|
||||
Timber.tag(TAG).w("Tx $txHash not confirmed after $MAX_ATTEMPTS attempts")
|
||||
// Note: Transaction might still confirm later, but we stop polling
|
||||
// The status remains PENDING, not FAILED, because the tx might still be valid
|
||||
}
|
||||
|
||||
Timber.tag(TAG).d("Stopped polling for tx: $txHash (final status: $lastStatus)")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.di
|
||||
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* DI module providing wallet-related dependencies.
|
||||
*
|
||||
* Note: CardanoClient binding is handled via @ContributesBinding
|
||||
* annotation on KoiosCardanoClient.
|
||||
*/
|
||||
@ContributesTo(AppScope::class)
|
||||
@BindingContainer
|
||||
interface WalletModule {
|
||||
companion object {
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun provideWalletJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* A non-dismissible confirmation dialog for wallet deletion with a clear warning.
|
||||
*/
|
||||
@Composable
|
||||
fun WalletDeleteConfirmationDialog(
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Block back button - must explicitly choose Cancel or Delete
|
||||
BackHandler(enabled = true) {
|
||||
// Intentionally empty - prevent back press from dismissing
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
// Cannot dismiss by tapping outside - must choose an action
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Delete Wallet?",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
Text(
|
||||
text = "This will permanently remove your wallet from this device. If you haven't backed up your recovery phrase, " +
|
||||
"you will lose access to your funds forever.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Make sure you have:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "• Written down your 24-word recovery phrase, OR\n• Backed up to Matrix",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = "Delete Wallet",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.annotations.ContributesNode
|
||||
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
/**
|
||||
* Node for displaying the wallet panel.
|
||||
*/
|
||||
@ContributesNode(SessionScope::class)
|
||||
class WalletPanelNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: WalletPanelPresenter,
|
||||
) : Node(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
/**
|
||||
* Callback interface for wallet panel navigation events.
|
||||
*/
|
||||
interface Callback : Plugin {
|
||||
fun onClose()
|
||||
fun onSendAda()
|
||||
fun onSetupWallet()
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val context = LocalContext.current
|
||||
|
||||
WalletPanelView(
|
||||
state = state.copy(
|
||||
eventSink = { event ->
|
||||
when (event) {
|
||||
is WalletPanelEvent.OpenTransaction -> {
|
||||
val url = "${CardanoNetworkConfig.EXPLORER_BASE_URL}/transaction/${event.txHash}"
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
}
|
||||
else -> state.eventSink(event)
|
||||
}
|
||||
}
|
||||
),
|
||||
onBackClick = { callback.onClose() },
|
||||
onSendClick = { callback.onSendAda() },
|
||||
onSetupClick = { callback.onSetupWallet() },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.wallet.api.CardanoClient
|
||||
import io.element.android.features.wallet.api.NativeAsset
|
||||
import io.element.android.features.wallet.api.NftMetadata
|
||||
import io.element.android.features.wallet.api.TxSummary
|
||||
import io.element.android.features.wallet.api.backup.WalletBackupService
|
||||
import io.element.android.features.wallet.api.storage.CardanoKeyStorage
|
||||
import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig
|
||||
import io.element.android.features.wallet.impl.cardano.CardanoWalletManager
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Presenter for the wallet panel.
|
||||
*/
|
||||
class WalletPanelPresenter @Inject constructor(
|
||||
private val walletManager: CardanoWalletManager,
|
||||
private val cardanoClient: CardanoClient,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val walletBackupService: WalletBackupService,
|
||||
private val keyStorage: CardanoKeyStorage,
|
||||
) : Presenter<WalletPanelState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): WalletPanelState {
|
||||
val walletState by walletManager.walletState.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var assets by remember { mutableStateOf<List<NativeAsset>>(emptyList()) }
|
||||
var transactions by remember { mutableStateOf<List<TxSummary>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Mnemonic dialog state
|
||||
var requestBiometricAuth by remember { mutableStateOf(false) }
|
||||
var showMnemonicDialog by remember { mutableStateOf(false) }
|
||||
var mnemonicWords by remember { mutableStateOf<List<String>?>(null) }
|
||||
var mnemonicError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// SSSS Backup state
|
||||
var showBackupDialog by remember { mutableStateOf(false) }
|
||||
var backupMode by remember { mutableStateOf(BackupMode.BACKUP) }
|
||||
var backupInProgress by remember { mutableStateOf(false) }
|
||||
var backupError by remember { mutableStateOf<String?>(null) }
|
||||
var backupSuccess by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Delete confirmation state
|
||||
var showDeleteConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
// Initialize wallet on first composition
|
||||
LaunchedEffect(Unit) {
|
||||
walletManager.initialize(matrixClient.sessionId)
|
||||
}
|
||||
|
||||
// Load assets and transactions when we have an address
|
||||
LaunchedEffect(walletState.address) {
|
||||
val address = walletState.address ?: run { isLoading = false; return@LaunchedEffect }
|
||||
|
||||
isLoading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
// Fetch balance
|
||||
val balanceResult = cardanoClient.getBalance(address)
|
||||
balanceResult.onSuccess { balance ->
|
||||
walletManager.refreshBalance(matrixClient.sessionId, balance)
|
||||
}
|
||||
|
||||
// Fetch assets and enrich with NFT metadata
|
||||
cardanoClient.getAddressAssets(address)
|
||||
.onSuccess { fetchedAssets ->
|
||||
assets = enrichAssetsWithMetadata(fetchedAssets)
|
||||
}
|
||||
.onFailure { Timber.w(it, "Failed to fetch assets") }
|
||||
|
||||
// Fetch transactions
|
||||
cardanoClient.getAddressTransactions(address, 20)
|
||||
.onSuccess { transactions = it }
|
||||
.onFailure { Timber.w(it, "Failed to fetch transactions") }
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to load wallet data")
|
||||
error = e.message
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
fun handleEvent(event: WalletPanelEvent) {
|
||||
when (event) {
|
||||
WalletPanelEvent.Refresh -> {
|
||||
// Trigger refresh - handled by LaunchedEffect
|
||||
}
|
||||
WalletPanelEvent.ExportRecoveryPhrase -> {
|
||||
// Signal the view to trigger biometric auth
|
||||
requestBiometricAuth = true
|
||||
}
|
||||
WalletPanelEvent.CancelBiometricAuth -> {
|
||||
requestBiometricAuth = false
|
||||
}
|
||||
WalletPanelEvent.LoadMnemonic -> {
|
||||
requestBiometricAuth = false
|
||||
scope.launch {
|
||||
mnemonicError = null
|
||||
walletManager.getMnemonic(matrixClient.sessionId)
|
||||
.onSuccess { words ->
|
||||
mnemonicWords = words
|
||||
showMnemonicDialog = true
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "Failed to get mnemonic")
|
||||
mnemonicError = e.message ?: "Failed to retrieve recovery phrase"
|
||||
}
|
||||
}
|
||||
}
|
||||
WalletPanelEvent.DismissMnemonicDialog -> {
|
||||
showMnemonicDialog = false
|
||||
mnemonicWords = null
|
||||
mnemonicError = null
|
||||
}
|
||||
WalletPanelEvent.DeleteWallet -> {
|
||||
// Show confirmation dialog
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
WalletPanelEvent.ConfirmDeleteWallet -> {
|
||||
scope.launch {
|
||||
Timber.i("Deleting wallet for session ${matrixClient.sessionId}")
|
||||
keyStorage.deleteWallet(matrixClient.sessionId)
|
||||
.onSuccess {
|
||||
Timber.i("Wallet deleted successfully")
|
||||
showDeleteConfirmation = false
|
||||
// Reset wallet state - this will cause the panel to show setup prompt
|
||||
walletManager.clearState()
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "Failed to delete wallet")
|
||||
error = e.message ?: "Failed to delete wallet"
|
||||
showDeleteConfirmation = false
|
||||
}
|
||||
}
|
||||
}
|
||||
WalletPanelEvent.CancelDeleteWallet -> {
|
||||
showDeleteConfirmation = false
|
||||
}
|
||||
is WalletPanelEvent.OpenTransaction -> {
|
||||
// Handled by view via intent
|
||||
}
|
||||
WalletPanelEvent.Close -> {
|
||||
// Navigation handled by node callback
|
||||
}
|
||||
// SSSS Backup events
|
||||
WalletPanelEvent.ShowBackupDialog -> {
|
||||
backupMode = BackupMode.BACKUP
|
||||
backupError = null
|
||||
backupSuccess = null
|
||||
showBackupDialog = true
|
||||
}
|
||||
WalletPanelEvent.ShowRestoreDialog -> {
|
||||
backupMode = BackupMode.RESTORE
|
||||
backupError = null
|
||||
backupSuccess = null
|
||||
showBackupDialog = true
|
||||
}
|
||||
WalletPanelEvent.DismissBackupDialog -> {
|
||||
showBackupDialog = false
|
||||
backupError = null
|
||||
}
|
||||
is WalletPanelEvent.ConfirmBackup -> {
|
||||
scope.launch {
|
||||
backupInProgress = true
|
||||
backupError = null
|
||||
|
||||
// Normalize recovery key: remove spaces and convert to lowercase
|
||||
val normalizedKey = event.recoveryKey.replace("\\s+".toRegex(), "").lowercase()
|
||||
|
||||
walletManager.getMnemonic(matrixClient.sessionId)
|
||||
.onSuccess { mnemonic ->
|
||||
walletBackupService.backupSeed(normalizedKey, mnemonic)
|
||||
.onSuccess {
|
||||
Timber.i("Wallet backed up to SSSS successfully")
|
||||
backupSuccess = "Wallet backed up successfully"
|
||||
showBackupDialog = false
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "Failed to backup wallet to SSSS")
|
||||
backupError = e.message ?: "Failed to backup wallet"
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "Failed to get mnemonic for backup")
|
||||
backupError = e.message ?: "Failed to retrieve wallet data"
|
||||
}
|
||||
|
||||
backupInProgress = false
|
||||
}
|
||||
}
|
||||
is WalletPanelEvent.ConfirmRestore -> {
|
||||
scope.launch {
|
||||
backupInProgress = true
|
||||
backupError = null
|
||||
|
||||
// Normalize recovery key: remove spaces and convert to lowercase
|
||||
val normalizedKey = event.recoveryKey.replace("\\s+".toRegex(), "").lowercase()
|
||||
|
||||
walletBackupService.restoreSeed(normalizedKey)
|
||||
.onSuccess { mnemonic ->
|
||||
if (mnemonic != null) {
|
||||
// First delete existing wallet if any
|
||||
keyStorage.deleteWallet(matrixClient.sessionId)
|
||||
|
||||
// Import the restored mnemonic
|
||||
keyStorage.importWallet(matrixClient.sessionId, mnemonic)
|
||||
.onSuccess {
|
||||
Timber.i("Wallet restored from SSSS successfully")
|
||||
backupSuccess = "Wallet restored successfully"
|
||||
showBackupDialog = false
|
||||
// Reinitialize wallet state
|
||||
walletManager.initialize(matrixClient.sessionId)
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "Failed to import restored wallet")
|
||||
backupError = e.message ?: "Failed to import wallet"
|
||||
}
|
||||
} else {
|
||||
backupError = "No wallet backup found in Matrix"
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.e(e, "Failed to restore wallet from SSSS")
|
||||
backupError = e.message ?: "Failed to restore wallet"
|
||||
}
|
||||
|
||||
backupInProgress = false
|
||||
}
|
||||
}
|
||||
WalletPanelEvent.ClearBackupMessage -> {
|
||||
backupError = null
|
||||
backupSuccess = null
|
||||
}
|
||||
else -> {
|
||||
// Other events handled elsewhere
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return WalletPanelState(
|
||||
hasWallet = walletState.hasWallet,
|
||||
isLoading = isLoading || walletState.isLoading,
|
||||
address = walletState.address,
|
||||
balanceLovelace = walletState.balanceLovelace,
|
||||
balanceAda = walletState.balanceAda,
|
||||
assets = assets,
|
||||
transactions = transactions,
|
||||
isTestnet = CardanoNetworkConfig.NETWORK_NAME != "mainnet",
|
||||
error = error ?: walletState.error,
|
||||
requestBiometricAuth = requestBiometricAuth,
|
||||
showMnemonicDialog = showMnemonicDialog,
|
||||
mnemonicWords = mnemonicWords,
|
||||
mnemonicError = mnemonicError,
|
||||
showBackupDialog = showBackupDialog,
|
||||
backupMode = backupMode,
|
||||
backupInProgress = backupInProgress,
|
||||
backupError = backupError,
|
||||
backupSuccess = backupSuccess,
|
||||
showDeleteConfirmation = showDeleteConfirmation,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich assets with NFT metadata from Koios.
|
||||
* Fetches CIP-25 metadata for potential NFTs (quantity == 1) in parallel.
|
||||
*/
|
||||
private suspend fun enrichAssetsWithMetadata(assets: List<NativeAsset>): List<NativeAsset> {
|
||||
if (assets.isEmpty()) return assets
|
||||
|
||||
// Identify potential NFTs (quantity == 1 or marked as NFT)
|
||||
val potentialNfts = assets.filter { it.quantity == 1L || it.isNft }
|
||||
if (potentialNfts.isEmpty()) return assets
|
||||
|
||||
Timber.d("Enriching ${potentialNfts.size} potential NFTs with metadata")
|
||||
|
||||
// Fetch metadata in parallel (max 10 concurrent to avoid rate limiting)
|
||||
val metadataMap = mutableMapOf<String, NftMetadata>()
|
||||
try {
|
||||
coroutineScope {
|
||||
potentialNfts.chunked(10).forEach { chunk ->
|
||||
chunk.map { asset ->
|
||||
async {
|
||||
cardanoClient.getNftMetadata(asset.policyId, asset.assetName)
|
||||
.onSuccess { metadata ->
|
||||
if (metadata != null) {
|
||||
metadataMap[asset.unit] = metadata
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
Timber.w(e, "Failed to fetch metadata for ${asset.unit}")
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Error during metadata enrichment, continuing without full metadata")
|
||||
}
|
||||
|
||||
Timber.d("Successfully fetched metadata for ${metadataMap.size} NFTs")
|
||||
|
||||
// Apply metadata to assets
|
||||
return assets.map { asset ->
|
||||
val metadata = metadataMap[asset.unit]
|
||||
if (metadata != null) {
|
||||
asset.copy(
|
||||
displayName = metadata.name.takeIf { it.isNotEmpty() } ?: asset.displayName,
|
||||
imageUrl = metadata.image,
|
||||
description = metadata.description,
|
||||
isNft = metadata.image != null,
|
||||
)
|
||||
} else {
|
||||
asset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.wallet.api.NativeAsset
|
||||
import io.element.android.features.wallet.api.TxSummary
|
||||
|
||||
/**
|
||||
* UI state for the wallet panel.
|
||||
*/
|
||||
@Immutable
|
||||
data class WalletPanelState(
|
||||
val hasWallet: Boolean,
|
||||
val isLoading: Boolean,
|
||||
val address: String?,
|
||||
val balanceLovelace: Long?,
|
||||
val balanceAda: String?,
|
||||
val assets: List<NativeAsset>,
|
||||
val transactions: List<TxSummary>,
|
||||
val isTestnet: Boolean,
|
||||
val error: String?,
|
||||
val requestBiometricAuth: Boolean,
|
||||
val showMnemonicDialog: Boolean,
|
||||
val mnemonicWords: List<String>?,
|
||||
val mnemonicError: String?,
|
||||
// SSSS Backup state
|
||||
val showBackupDialog: Boolean,
|
||||
val backupMode: BackupMode,
|
||||
val backupInProgress: Boolean,
|
||||
val backupError: String?,
|
||||
val backupSuccess: String?,
|
||||
// Delete confirmation state
|
||||
val showDeleteConfirmation: Boolean,
|
||||
val eventSink: (WalletPanelEvent) -> Unit,
|
||||
) {
|
||||
companion object {
|
||||
val Initial = WalletPanelState(
|
||||
hasWallet = false,
|
||||
isLoading = true,
|
||||
address = null,
|
||||
balanceLovelace = null,
|
||||
balanceAda = null,
|
||||
assets = emptyList(),
|
||||
transactions = emptyList(),
|
||||
isTestnet = true,
|
||||
error = null,
|
||||
requestBiometricAuth = false,
|
||||
showMnemonicDialog = false,
|
||||
mnemonicWords = null,
|
||||
mnemonicError = null,
|
||||
showBackupDialog = false,
|
||||
backupMode = BackupMode.BACKUP,
|
||||
backupInProgress = false,
|
||||
backupError = null,
|
||||
backupSuccess = null,
|
||||
showDeleteConfirmation = false,
|
||||
eventSink = {},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncated address for display (first 12 + last 8 chars).
|
||||
*/
|
||||
val truncatedAddress: String?
|
||||
get() = address?.let { addr ->
|
||||
if (addr.length > 24) {
|
||||
"${addr.take(12)}...${addr.takeLast(8)}"
|
||||
} else {
|
||||
addr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup operation mode.
|
||||
*/
|
||||
enum class BackupMode {
|
||||
BACKUP,
|
||||
RESTORE
|
||||
}
|
||||
|
||||
/**
|
||||
* Events that can be triggered from the wallet panel UI.
|
||||
*/
|
||||
sealed interface WalletPanelEvent {
|
||||
/** Refresh wallet data from the network. */
|
||||
data object Refresh : WalletPanelEvent
|
||||
|
||||
/** Navigate to send ADA flow. */
|
||||
data object SendAda : WalletPanelEvent
|
||||
|
||||
/** Copy address to clipboard. */
|
||||
data object CopyAddress : WalletPanelEvent
|
||||
|
||||
/** Navigate to wallet setup flow. */
|
||||
data object SetupWallet : WalletPanelEvent
|
||||
|
||||
/** Export recovery phrase (triggers biometric auth). */
|
||||
data object ExportRecoveryPhrase : WalletPanelEvent
|
||||
|
||||
/** Called after successful biometric auth to load mnemonic. */
|
||||
data object LoadMnemonic : WalletPanelEvent
|
||||
|
||||
/** Cancel the biometric auth request. */
|
||||
data object CancelBiometricAuth : WalletPanelEvent
|
||||
|
||||
/** Dismiss the mnemonic dialog. */
|
||||
data object DismissMnemonicDialog : WalletPanelEvent
|
||||
|
||||
/** Show delete confirmation dialog. */
|
||||
data object DeleteWallet : WalletPanelEvent
|
||||
|
||||
/** Confirm wallet deletion. */
|
||||
data object ConfirmDeleteWallet : WalletPanelEvent
|
||||
|
||||
/** Cancel wallet deletion / dismiss dialog. */
|
||||
data object CancelDeleteWallet : WalletPanelEvent
|
||||
|
||||
/** Open transaction in block explorer. */
|
||||
data class OpenTransaction(val txHash: String) : WalletPanelEvent
|
||||
|
||||
/** Close the panel. */
|
||||
data object Close : WalletPanelEvent
|
||||
|
||||
// SSSS Backup events
|
||||
/** Show backup dialog to enter recovery key. */
|
||||
data object ShowBackupDialog : WalletPanelEvent
|
||||
|
||||
/** Show restore dialog to enter recovery key. */
|
||||
data object ShowRestoreDialog : WalletPanelEvent
|
||||
|
||||
/** Dismiss the backup/restore dialog. */
|
||||
data object DismissBackupDialog : WalletPanelEvent
|
||||
|
||||
/** Confirm backup with the provided recovery key. */
|
||||
data class ConfirmBackup(val recoveryKey: String) : WalletPanelEvent
|
||||
|
||||
/** Confirm restore with the provided recovery key. */
|
||||
data class ConfirmRestore(val recoveryKey: String) : WalletPanelEvent
|
||||
|
||||
/** Clear backup success/error message. */
|
||||
data object ClearBackupMessage : WalletPanelEvent
|
||||
}
|
||||
|
|
@ -0,0 +1,511 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.wallet.impl.R
|
||||
import io.element.android.features.wallet.impl.panel.tabs.AssetsTabView
|
||||
import io.element.android.features.wallet.impl.panel.tabs.HistoryTabView
|
||||
import io.element.android.features.wallet.impl.panel.tabs.OverviewTabView
|
||||
import io.element.android.features.wallet.impl.panel.tabs.SettingsTabView
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
private enum class WalletTab(val titleRes: Int) {
|
||||
Overview(R.string.wallet_tab_overview),
|
||||
Assets(R.string.wallet_tab_assets),
|
||||
History(R.string.wallet_tab_history),
|
||||
Settings(R.string.wallet_tab_settings),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WalletPanelView(
|
||||
state: WalletPanelState,
|
||||
onBackClick: () -> Unit,
|
||||
onSendClick: () -> Unit,
|
||||
onSetupClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tabs = WalletTab.entries
|
||||
val pagerState = rememberPagerState(pageCount = { tabs.size })
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
val activity = context as? FragmentActivity
|
||||
|
||||
// Handle biometric authentication request
|
||||
LaunchedEffect(state.requestBiometricAuth) {
|
||||
if (state.requestBiometricAuth && activity != null) {
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
val canAuth = biometricManager.canAuthenticate(
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
if (canAuth) {
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
val callback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
state.eventSink(WalletPanelEvent.LoadMnemonic)
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
Timber.w("Biometric auth error: $errorCode - $errString")
|
||||
state.eventSink(WalletPanelEvent.CancelBiometricAuth)
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
// User can retry
|
||||
}
|
||||
}
|
||||
|
||||
val biometricPrompt = BiometricPrompt(activity, executor, callback)
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle("Confirm your identity")
|
||||
.setSubtitle("Authenticate to view recovery phrase")
|
||||
.setAllowedAuthenticators(
|
||||
BiometricManager.Authenticators.BIOMETRIC_WEAK or
|
||||
BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
)
|
||||
.build()
|
||||
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
} else {
|
||||
// No biometric/credential available, proceed directly
|
||||
state.eventSink(WalletPanelEvent.LoadMnemonic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show mnemonic dialog
|
||||
if (state.showMnemonicDialog && state.mnemonicWords != null) {
|
||||
MnemonicDisplayDialog(
|
||||
words = state.mnemonicWords,
|
||||
onDismiss = { state.eventSink(WalletPanelEvent.DismissMnemonicDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
// Show backup/restore dialog
|
||||
if (state.showBackupDialog) {
|
||||
BackupRecoveryKeyDialog(
|
||||
mode = state.backupMode,
|
||||
isLoading = state.backupInProgress,
|
||||
error = state.backupError,
|
||||
onConfirm = { recoveryKey ->
|
||||
when (state.backupMode) {
|
||||
BackupMode.BACKUP -> state.eventSink(WalletPanelEvent.ConfirmBackup(recoveryKey))
|
||||
BackupMode.RESTORE -> state.eventSink(WalletPanelEvent.ConfirmRestore(recoveryKey))
|
||||
}
|
||||
},
|
||||
onDismiss = { state.eventSink(WalletPanelEvent.DismissBackupDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
// Show delete confirmation dialog
|
||||
if (state.showDeleteConfirmation) {
|
||||
WalletDeleteConfirmationDialog(
|
||||
onConfirm = { state.eventSink(WalletPanelEvent.ConfirmDeleteWallet) },
|
||||
onDismiss = { state.eventSink(WalletPanelEvent.CancelDeleteWallet) }
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.wallet_panel_title)) },
|
||||
navigationIcon = {
|
||||
BackButton(onClick = onBackClick)
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
if (!state.hasWallet && !state.isLoading) {
|
||||
// Show setup prompt
|
||||
WalletSetupPromptView(
|
||||
onSetupClick = onSetupClick,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
},
|
||||
text = { Text(stringResource(tab.titleRes)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) { page ->
|
||||
when (tabs[page]) {
|
||||
WalletTab.Overview -> OverviewTabView(
|
||||
state = state,
|
||||
onSendClick = onSendClick,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
WalletTab.Assets -> AssetsTabView(
|
||||
assets = state.assets,
|
||||
isLoading = state.isLoading,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
WalletTab.History -> HistoryTabView(
|
||||
transactions = state.transactions,
|
||||
isTestnet = state.isTestnet,
|
||||
isLoading = state.isLoading,
|
||||
onTransactionClick = { txHash ->
|
||||
state.eventSink(WalletPanelEvent.OpenTransaction(txHash))
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
WalletTab.Settings -> SettingsTabView(
|
||||
address = state.address,
|
||||
isTestnet = state.isTestnet,
|
||||
onCopyAddress = { state.eventSink(WalletPanelEvent.CopyAddress) },
|
||||
onExportPhrase = { state.eventSink(WalletPanelEvent.ExportRecoveryPhrase) },
|
||||
onBackupToMatrix = { state.eventSink(WalletPanelEvent.ShowBackupDialog) },
|
||||
onDeleteWallet = { state.eventSink(WalletPanelEvent.DeleteWallet) },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackupRecoveryKeyDialog(
|
||||
mode: BackupMode,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var recoveryKey by remember { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!isLoading) onDismiss() },
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = !isLoading,
|
||||
dismissOnClickOutside = !isLoading,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_backup_dialog_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_backup_dialog_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = recoveryKey,
|
||||
onValueChange = { recoveryKey = it },
|
||||
label = { Text(stringResource(R.string.wallet_backup_dialog_hint)) },
|
||||
enabled = !isLoading,
|
||||
singleLine = false,
|
||||
minLines = 2,
|
||||
maxLines = 4,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = error != null,
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onConfirm(recoveryKey) },
|
||||
enabled = !isLoading && recoveryKey.isNotBlank(),
|
||||
) {
|
||||
Text(
|
||||
text = when (mode) {
|
||||
BackupMode.BACKUP -> stringResource(R.string.wallet_backup_dialog_backup)
|
||||
BackupMode.RESTORE -> stringResource(R.string.wallet_backup_dialog_restore)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
enabled = !isLoading,
|
||||
) {
|
||||
Text(stringResource(R.string.wallet_backup_dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun MnemonicDisplayDialog(
|
||||
words: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? android.app.Activity
|
||||
|
||||
// Set FLAG_SECURE to prevent screenshots while dialog is shown
|
||||
DisposableEffect(Unit) {
|
||||
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
onDispose {
|
||||
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
title = {
|
||||
Text(
|
||||
text = "Recovery Phrase",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Write down these 24 words in order and store them safely. " +
|
||||
"Never share your recovery phrase with anyone.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
// 4 columns x 6 rows grid
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
maxItemsInEachRow = 4,
|
||||
) {
|
||||
words.forEachIndexed { index, word ->
|
||||
WordChip(
|
||||
number = index + 1,
|
||||
word = word,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Done")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WordChip(
|
||||
number: Int,
|
||||
word: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(width = 80.dp, height = 36.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "$number. $word",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WalletSetupPromptView(
|
||||
onSetupClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = CompoundIcons.Chart(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.then(Modifier.padding(48.dp)),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_setup_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_setup_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(bottom = 24.dp),
|
||||
)
|
||||
Button(onClick = onSetupClick) {
|
||||
Text(stringResource(R.string.wallet_setup_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun WalletPanelViewPreview() = ElementPreview {
|
||||
WalletPanelView(
|
||||
state = WalletPanelState(
|
||||
hasWallet = true,
|
||||
isLoading = false,
|
||||
address = "addr_test1qpu5vlrf4xkxs2m4wcn7hpq98aqspflj3tdx8ax9qk9qw8zqh2c4tkqehp4j0y8awxmjcgv5p2vz8z5zycq7vq4q2dqst7pf8y",
|
||||
balanceLovelace = 5_500_000L,
|
||||
balanceAda = "5.5",
|
||||
assets = emptyList(),
|
||||
transactions = emptyList(),
|
||||
isTestnet = true,
|
||||
error = null,
|
||||
requestBiometricAuth = false,
|
||||
showMnemonicDialog = false,
|
||||
mnemonicWords = null,
|
||||
mnemonicError = null,
|
||||
showBackupDialog = false,
|
||||
backupMode = BackupMode.BACKUP,
|
||||
backupInProgress = false,
|
||||
backupError = null,
|
||||
backupSuccess = null,
|
||||
showDeleteConfirmation = false,
|
||||
eventSink = {},
|
||||
),
|
||||
onBackClick = {},
|
||||
onSendClick = {},
|
||||
onSetupClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun WalletPanelViewNoWalletPreview() = ElementPreview {
|
||||
WalletPanelView(
|
||||
state = WalletPanelState.Initial.copy(
|
||||
hasWallet = false,
|
||||
isLoading = false,
|
||||
),
|
||||
onBackClick = {},
|
||||
onSendClick = {},
|
||||
onSetupClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,412 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel.tabs
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.wallet.api.NativeAsset
|
||||
import io.element.android.features.wallet.impl.R
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun AssetsTabView(
|
||||
assets: List<NativeAsset>,
|
||||
isLoading: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var selectedNft by remember { mutableStateOf<NativeAsset?>(null) }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
when {
|
||||
isLoading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
assets.isEmpty() -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Files(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_no_assets),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(assets) { asset ->
|
||||
AssetCard(
|
||||
asset = asset,
|
||||
onClick = { if (asset.isNft || asset.imageUrl != null) selectedNft = asset },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NFT Detail Bottom Sheet
|
||||
selectedNft?.let { nft ->
|
||||
NftDetailBottomSheet(
|
||||
asset = nft,
|
||||
onDismiss = { selectedNft = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssetCard(
|
||||
asset: NativeAsset,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasImage = asset.imageUrl != null
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (hasImage) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// NFT Thumbnail (64dp square, 8dp rounded corners)
|
||||
if (hasImage) {
|
||||
NftThumbnail(
|
||||
imageUrl = asset.imageUrl!!,
|
||||
contentDescription = asset.name,
|
||||
modifier = Modifier.size(64.dp),
|
||||
)
|
||||
} else {
|
||||
// Placeholder for non-NFT tokens
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Info(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Asset info
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = asset.name,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = asset.truncatedPolicyId,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (asset.isNft) {
|
||||
Text(
|
||||
text = "NFT",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Quantity
|
||||
Text(
|
||||
text = asset.formatQuantity(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NftThumbnail(
|
||||
imageUrl: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
onState = { state ->
|
||||
isLoading = state is AsyncImagePainter.State.Loading
|
||||
isError = state is AsyncImagePainter.State.Error
|
||||
},
|
||||
)
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
|
||||
// Error placeholder
|
||||
if (isError) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Error(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun NftDetailBottomSheet(
|
||||
asset: NativeAsset,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Large NFT image
|
||||
asset.imageUrl?.let { url ->
|
||||
NftDetailImage(
|
||||
imageUrl = url,
|
||||
contentDescription = asset.name,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// NFT Name
|
||||
Text(
|
||||
text = asset.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Policy ID
|
||||
Text(
|
||||
text = asset.truncatedPolicyId,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
|
||||
// Description if available
|
||||
asset.description?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Quantity badge
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Quantity: ${asset.formatQuantity()}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NftDetailImage(
|
||||
imageUrl: String,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
onState = { state ->
|
||||
isLoading = state is AsyncImagePainter.State.Loading
|
||||
isError = state is AsyncImagePainter.State.Error
|
||||
},
|
||||
)
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
// Error placeholder
|
||||
if (isError) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Error(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Failed to load image",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AssetsTabViewPreview() = ElementPreview {
|
||||
AssetsTabView(
|
||||
assets = listOf(
|
||||
NativeAsset(
|
||||
policyId = "aabbccdd11223344556677889900aabbccdd11223344556677889900",
|
||||
assetName = "4d79546f6b656e",
|
||||
quantity = 1000,
|
||||
displayName = "MyToken",
|
||||
fingerprint = null,
|
||||
),
|
||||
NativeAsset(
|
||||
policyId = "11223344556677889900aabbccdd11223344556677889900aabbccdd",
|
||||
assetName = "436f6f6c4e4654",
|
||||
quantity = 1,
|
||||
displayName = "CoolNFT",
|
||||
fingerprint = null,
|
||||
imageUrl = "https://ipfs.io/ipfs/QmTest123",
|
||||
isNft = true,
|
||||
description = "A really cool NFT from the Cardano blockchain",
|
||||
),
|
||||
),
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun AssetsTabViewEmptyPreview() = ElementPreview {
|
||||
AssetsTabView(
|
||||
assets = emptyList(),
|
||||
isLoading = false,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel.tabs
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.wallet.api.TxSummary
|
||||
import io.element.android.features.wallet.impl.R
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun HistoryTabView(
|
||||
transactions: List<TxSummary>,
|
||||
isTestnet: Boolean,
|
||||
isLoading: Boolean,
|
||||
onTransactionClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
when {
|
||||
isLoading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
transactions.isEmpty() -> {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.History(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_no_transactions),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(transactions) { tx ->
|
||||
TransactionCard(
|
||||
transaction = tx,
|
||||
isTestnet = isTestnet,
|
||||
onClick = { onTransactionClick(tx.txHash) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TransactionCard(
|
||||
transaction: TxSummary,
|
||||
isTestnet: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = when (transaction.direction) {
|
||||
TxSummary.Direction.SENT -> CompoundIcons.ArrowUpRight()
|
||||
TxSummary.Direction.RECEIVED -> CompoundIcons.ArrowDown()
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = when (transaction.direction) {
|
||||
TxSummary.Direction.SENT -> MaterialTheme.colorScheme.error
|
||||
TxSummary.Direction.RECEIVED -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = when (transaction.direction) {
|
||||
TxSummary.Direction.SENT -> stringResource(R.string.wallet_tx_sent)
|
||||
TxSummary.Direction.RECEIVED -> stringResource(R.string.wallet_tx_received)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = transaction.formattedDate,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = transaction.truncatedTxHash,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
Text(
|
||||
text = transaction.amountAda,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = when (transaction.direction) {
|
||||
TxSummary.Direction.SENT -> MaterialTheme.colorScheme.error
|
||||
TxSummary.Direction.RECEIVED -> MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
Icon(
|
||||
imageVector = CompoundIcons.PopOut(),
|
||||
contentDescription = stringResource(R.string.wallet_view_on_explorer),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HistoryTabViewPreview() = ElementPreview {
|
||||
HistoryTabView(
|
||||
transactions = listOf(
|
||||
TxSummary(
|
||||
txHash = "aabbccdd11223344556677889900aabbccdd11223344556677889900aabbccdd",
|
||||
blockTime = 1710000000,
|
||||
totalOutput = 5_500_000,
|
||||
fee = 170000,
|
||||
direction = TxSummary.Direction.SENT,
|
||||
),
|
||||
TxSummary(
|
||||
txHash = "11223344556677889900aabbccdd11223344556677889900aabbccdd11223344",
|
||||
blockTime = 1709900000,
|
||||
totalOutput = 10_000_000,
|
||||
fee = 165000,
|
||||
direction = TxSummary.Direction.RECEIVED,
|
||||
),
|
||||
),
|
||||
isTestnet = true,
|
||||
isLoading = false,
|
||||
onTransactionClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun HistoryTabViewEmptyPreview() = ElementPreview {
|
||||
HistoryTabView(
|
||||
transactions = emptyList(),
|
||||
isTestnet = true,
|
||||
isLoading = false,
|
||||
onTransactionClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel.tabs
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.wallet.impl.R
|
||||
import io.element.android.features.wallet.impl.panel.WalletPanelEvent
|
||||
import io.element.android.features.wallet.impl.panel.BackupMode
|
||||
import io.element.android.features.wallet.impl.panel.WalletPanelState
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun OverviewTabView(
|
||||
state: WalletPanelState,
|
||||
onSendClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Balance Card
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_balance_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "${state.balanceAda ?: "0"} ADA",
|
||||
style = MaterialTheme.typography.displaySmall.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
if (state.isTestnet) {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_testnet_label),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QR Code
|
||||
state.address?.let { address ->
|
||||
val qrBitmap = remember(address) {
|
||||
generateQrCode(address, 200)
|
||||
}
|
||||
qrBitmap?.let { bitmap ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.White)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.wallet_qr_code_description),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Address
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
clipboardManager.setText(AnnotatedString(address))
|
||||
state.eventSink(WalletPanelEvent.CopyAddress)
|
||||
}
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = state.truncatedAddress ?: address,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Copy(),
|
||||
contentDescription = stringResource(R.string.wallet_copy_address),
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_tap_to_copy),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Send Button
|
||||
Button(
|
||||
onClick = onSendClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = state.hasWallet && !state.isLoading,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Send(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
Text(stringResource(R.string.wallet_send_ada))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun generateQrCode(content: String, size: Int): Bitmap? {
|
||||
return try {
|
||||
val hints = mutableMapOf<EncodeHintType, Any>()
|
||||
hints[EncodeHintType.MARGIN] = 0
|
||||
hints[EncodeHintType.CHARACTER_SET] = "UTF-8"
|
||||
|
||||
val writer = QRCodeWriter()
|
||||
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
|
||||
val pixels = IntArray(size * size)
|
||||
for (y in 0 until size) {
|
||||
for (x in 0 until size) {
|
||||
pixels[y * size + x] = if (bitMatrix[x, y]) {
|
||||
android.graphics.Color.BLACK
|
||||
} else {
|
||||
android.graphics.Color.WHITE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888).apply {
|
||||
setPixels(pixels, 0, size, 0, 0, size, size)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun OverviewTabViewPreview() = ElementPreview {
|
||||
OverviewTabView(
|
||||
state = WalletPanelState(
|
||||
hasWallet = true,
|
||||
isLoading = false,
|
||||
address = "addr_test1qpu5vlrf4xkxs2m4wcn7hpq98aqspflj3tdx8ax9qk9qw8zqh2c4tkqehp4j0y8awxmjcgv5p2vz8z5zycq7vq4q2dqst7pf8y",
|
||||
balanceLovelace = 25_500_000L,
|
||||
balanceAda = "25.5",
|
||||
assets = emptyList(),
|
||||
transactions = emptyList(),
|
||||
isTestnet = true,
|
||||
error = null,
|
||||
requestBiometricAuth = false,
|
||||
showMnemonicDialog = false,
|
||||
mnemonicWords = null,
|
||||
mnemonicError = null,
|
||||
showBackupDialog = false,
|
||||
backupMode = BackupMode.BACKUP,
|
||||
backupInProgress = false,
|
||||
backupError = null,
|
||||
backupSuccess = null,
|
||||
showDeleteConfirmation = false,
|
||||
eventSink = {},
|
||||
),
|
||||
onSendClick = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
/*
|
||||
* Copyright (c) 2026 Sulkta Coop.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package io.element.android.features.wallet.impl.panel.tabs
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.unit.dp
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.wallet.impl.R
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun SettingsTabView(
|
||||
address: String?,
|
||||
isTestnet: Boolean,
|
||||
onCopyAddress: () -> Unit,
|
||||
onExportPhrase: () -> Unit,
|
||||
onBackupToMatrix: () -> Unit,
|
||||
onDeleteWallet: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
// Wallet Address Section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_address),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = address ?: stringResource(R.string.wallet_settings_no_address),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onCopyAddress)
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Copy(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_copy_address),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Network Section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_network),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = if (isTestnet) {
|
||||
stringResource(R.string.wallet_settings_testnet)
|
||||
} else {
|
||||
stringResource(R.string.wallet_settings_mainnet)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
if (isTestnet) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = "TESTNET",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Security Section
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onExportPhrase)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Key(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_export_phrase),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_export_phrase_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ChevronRight(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
// Backup to Matrix
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onBackupToMatrix)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Cloud(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_backup_matrix),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_backup_matrix_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ChevronRight(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onDeleteWallet)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Delete(),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_delete_wallet),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_settings_delete_wallet_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SettingsTabViewPreview() = ElementPreview {
|
||||
SettingsTabView(
|
||||
address = "addr_test1qpu5vlrf4xkxs2m4wcn7hpq98aqspflj3tdx8ax9qk9qw8zqh2c4tkqehp4j0y8awxmjcgv5p2vz8z5zycq7vq4q2dqst7pf8y",
|
||||
isTestnet = true,
|
||||
onCopyAddress = {},
|
||||
onExportPhrase = {},
|
||||
onBackupToMatrix = {},
|
||||
onDeleteWallet = {},
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue