Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Session Android — build & run notes

Standard Gradle/Android project. See `ARCHITECTURE.md` for architecture and
`RELEASE.md` for the full build/release process.

## Toolchain

- JDK 17+ (JDK 21 works).
- Android SDK via `ANDROID_HOME` (set here to `~/Android/Sdk`) or `sdk.dir` in
`local.properties`.
- `./gradlew` wrapper; `adb` at `/usr/bin/adb`; the emulator binary is at
`$ANDROID_HOME/emulator/emulator` (not on PATH).

## Flavors and build types

- Distribution flavors: `play`, `fdroid`, `website`, `huawei`.
- `play` and `fdroid` are the Firebase-enabled variants (`firebaseEnabledVariants`
in `app/build.gradle.kts`): both include Firebase Cloud Messaging for push,
but exclude firebase core/analytics/measurement. `huawei` uses HMS push;
`website` uses no-op push wiring.
- Build types: `debug`, `release`, `releaseWithDebugMenu`, `qa`, `automaticQa`.
- CI (`.github/workflows/build_and_test.yml`) builds the `qa` type across all
flavors plus a `test` job.

## Build / install / run (play, for local testing)

```sh
./gradlew :app:assemblePlayDebug # build APK only (no device needed)
./gradlew :app:installPlayDebug # build + install to a running device/emulator
```

Huawei builds need the flag, e.g. `./gradlew assembleHuaweiDebug -Phuawei`.

### Emulator

Available AVDs (`$ANDROID_HOME/emulator/emulator -list-avds`) include
`Pixel_6_API_33`. Start one detached, then install:

```sh
"$ANDROID_HOME/emulator/emulator" -avd Pixel_6_API_33 &
adb wait-for-device # then poll sys.boot_completed
./gradlew :app:installPlayDebug
```

After install, confirm the package id (it is suffixed per flavor/build type):
`adb shell pm list packages | grep loki`, then launch it.

## Localization / strings — IMPORTANT

`app/src/main/res/values*/strings.xml` and `NonTranslatableStringConstants.kt`
are **generated from Crowdin — do not hand-edit them.** Text changes are made in
Crowdin and synced in by the `session-foundation/session-shared-scripts`
pipeline. See `ARCHITECTURE.md` §13 (Localization and Translations) for the full
flow, the approved-only export gate, and the ordering to ship a text change.

## Branching

- `master` = production; `dev` = integration branch; PRs target `dev`.
- Crowdin translation PRs land on branch `feature/update-crowdin-translations` → `dev`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Please search for any [existing issues](https://github.com/session-foundation/se

## Build instructions

Build instructions can be found in [BUILDING.md](BUILDING.md).
Build instructions can be found in [RELEASE.md](RELEASE.md).

## Translations

Expand Down
105 changes: 86 additions & 19 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,29 +63,37 @@ Gradle properties:
assembleFdroidRelease
```

The keystore file can be recreated from `release-creds.toml` — the `keystore` field in each
section is the JKS file base64-encoded, so it can be decoded back to a `.jks` file using any
standard base64 tool.
The keystore file can be recreated from the stored credentials — the `keystore` field in each
section is the keystore file base64-encoded, so it can be decoded back to a keystore file with
any standard base64 tool, e.g.:

```sh
python3 scripts/_keyring.py get release-creds \
| python3 -c 'import sys,tomllib,base64; open("keystore.jks","wb").write(base64.b64decode(tomllib.loads(sys.stdin.read())["build"]["play"]["keystore"]))'
```

---

## Release process

> **Quick checklist**
>
> 1. [ ] Create a `release/MAJOR.MINOR.PATCH` branch from `master`
> 2. [ ] Merge `dev` into the release branch (full release), or cherry-pick the relevant patch commits
> 3. [ ] Bump the version code
> 4. [ ] Create a GitHub release draft for the version (e.g. `1.23.4`) in this repository
> 5. [ ] Obtain `release-creds.toml` from a project maintainer and place it in the project root
> 6. [ ] Run `./scripts/build-and-release.py` from the release branch
> 7. [ ] Upload the AAB bundle to the Play Store
> 1. [ ] `scripts/prepare-release.py MAJOR.MINOR.PATCH` — creates the release branch off
> `master`, bumps the version, pushes, and creates the GitHub draft release
> 2. [ ] Merge `dev` into the release branch (full release) or cherry-pick patch commits, then push
> 3. [ ] `scripts/update-release-notes.py MAJOR.MINOR.PATCH` — (re)generate the draft's changelog
> 4. [ ] One-time: store the signing credentials in your keyring (see Credentials below)
> 5. [ ] `scripts/build-and-release.py` from the release branch — builds, opens the F-Droid PR,
> and uploads artifacts to the GitHub draft
> 6. [ ] Sign the release files and append the signature block (your release-signing script)
> 7. [ ] `scripts/play-store.py upload [--track …] [--submit|--rollout F]` — upload the AAB to Play
> 8. [ ] Review and merge the automated F-Droid PR in `session-foundation/session-fdroid`
> 9. [ ] Review and publish the GitHub release draft
> 9. [ ] Publish the GitHub draft: `gh release edit MAJOR.MINOR.PATCH --draft=false`

Steps 6–9 are explained in detail below. Steps 4 and 5 must be completed **before** running
the script — if no release draft exists the artifact upload is silently skipped, and without
the credentials file the script will exit immediately.
The GitHub draft (step 1) and the keyring credentials (step 4) must exist **before**
`build-and-release.py` runs: without a draft the artifact upload is silently skipped, and
without the credentials the script exits immediately. Run `update-release-notes.py` (step 3)
before the signing step (step 6), since regenerating the notes overwrites the whole release body.

### Branching strategy

Expand Down Expand Up @@ -119,17 +127,24 @@ Once the branch is ready, bump the version code in `app/build.gradle` (the `vers

### Prerequisites

- [**uv**](https://docs.astral.sh/uv/) — the script uses `uv run` to manage its Python
dependencies (`fdroidserver`) automatically
- **fdroidserver** — provides the `fdroid` command the script calls. Install it via your
system package manager: `apt install fdroidserver` (Debian/Ubuntu),
`brew install fdroidserver` (Homebrew), or `port install fdroidserver` (MacPorts).
Other methods (PPA, pip, Docker, …): <https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/>
- [**gh**](https://cli.github.com/) — GitHub CLI, authenticated and with access to
`session-foundation/session-android` and `session-foundation/session-fdroid`
- **Android SDK** — either `ANDROID_HOME` set in the environment, or `sdk.dir` defined in
`local.properties`. This would have been set up for you if you have opened the project in Android Studio.
- **keyring** — signing/publishing credentials are read from the OS keyring, not from disk.
Install: `apt install python3-keyring` (Linux) or `pip install keyring` (macOS).
- **Google API libraries** (only for `play-store.py`):
`apt install python3-google-auth python3-googleapi`
(or `pip install google-auth google-api-python-client`).

### Credentials file
### Credentials (OS keyring)

The script requires a `release-creds.toml` file in the project root. This file is not
committed to the repository — ask a project maintainer for it. Its structure is:
The signing credentials are a TOML document (ask a project maintainer for it) with this
structure:

```toml
[build.play]
Expand All @@ -151,6 +166,17 @@ key_alias = "<alias>"
key_password = "<password>"
```

Rather than leaving that file on disk, store its contents in the OS keyring once, then delete
the plaintext file:

```sh
python3 scripts/_keyring.py set release-creds < release-creds.toml
rm release-creds.toml
```

`build-and-release.py` reads it back from the keyring at build time. (The keyring is
cross-platform via the `keyring` library — macOS Keychain, Linux Secret Service, etc.)

### Running the script

From the project root, run:
Expand Down Expand Up @@ -196,6 +222,47 @@ For example, to perform builds without publishing anything:

---

## Publishing to the Play Store

`build-and-release.py` uploads the AAB to the *GitHub* release draft, not to Google Play.
`scripts/play-store.py` handles Play via the Play Developer API, with three subcommands:

- `play-store.py status` — show each track's releases (version code, status, rollout %)
- `play-store.py upload [AAB] --track T [--submit | --rollout F]` — upload an AAB
- `play-store.py rollout {FRACTION|complete|halt} --track T` — change a staged rollout without re-uploading

One-time setup:

- Create a Play service account (Google Cloud Console → IAM & Admin → Service Accounts),
download its JSON key, then grant it access in Play Console → **Users & permissions**
(invite the service-account email; give it **Release to production** + **Release to testing
tracks**). Store the key in the keyring and delete the plaintext:
```sh
python3 scripts/_keyring.py set play-service-account < service-account.json
rm service-account.json
```
- Install the Google API libraries (see Prerequisites).

Then, after `build-and-release.py` has produced the AAB:

```sh
scripts/play-store.py upload # draft on the internal track
scripts/play-store.py upload --track production --submit # full rollout
scripts/play-store.py upload --track production --rollout 0.1 # staged: 10% of users
scripts/play-store.py status # check what landed
```

`upload` with no flag leaves the release a **draft** (parked, not submitted). `--submit`
submits a full rollout; `--rollout F` submits a staged rollout to fraction `F` — then ramp it
with `play-store.py rollout …` (e.g. from cron; Play has no native auto-ramp). The AAB path
defaults to the play release bundle produced by `build-and-release.py`.

**Managed publishing:** with it on (Play Console → Publishing overview), a submitted release is
reviewed but **held until you click Publish** in the console — there is no API for that final
publish step, nor for querying review status. Keep it on for release control.

---

## Contributing code

Code contributions should be submitted via GitHub as pull requests from feature branches,
Expand Down
61 changes: 61 additions & 0 deletions scripts/_keyring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Cross-platform access to the OS keyring via the `keyring` library.

The `keyring` package selects the right backend per operating system:
- macOS: Keychain
- Linux: Secret Service (GNOME Keyring / KWallet, via secretstorage)
- Windows: Credential Locker (note: small blob size limit; not recommended
for the large base64 signing creds)

Install it with:
Debian/Ubuntu: apt install python3-keyring
macOS: pip install keyring (built-in Keychain backend)

Secrets are addressed by (SERVICE, name). Store one from a file -- which
preserves multi-line values -- using this module's CLI:
python3 scripts/_keyring.py set release-creds < release-creds.toml
then delete the plaintext file. `get`/`del` subcommands are also provided.
"""
import sys

SERVICE = "session-android"


def _keyring():
try:
import keyring
return keyring
except ImportError:
sys.exit("Python `keyring` library not found. Install it "
"(Debian/Ubuntu: `apt install python3-keyring`; macOS: `pip install keyring`).")


def get_secret(name, *, what):
"""Return the secret stored under (SERVICE, name), or exit with instructions."""
value = _keyring().get_password(SERVICE, name)
if not value:
sys.exit(f"{what} not found in the keyring (service '{SERVICE}', name '{name}').\n"
f"Store it once (reads the value from stdin, then delete the plaintext file):\n"
f" python3 scripts/_keyring.py set {name} < /path/to/secret")
return value


def _main(argv):
if len(argv) != 3 or argv[1] not in ("set", "get", "del"):
sys.exit("usage: _keyring.py {set|get|del} <name> (set reads the value from stdin)")
action, name = argv[1], argv[2]
kr = _keyring()
if action == "set":
kr.set_password(SERVICE, name, sys.stdin.read())
print(f"Stored secret (service '{SERVICE}', name '{name}').")
elif action == "get":
value = kr.get_password(SERVICE, name)
if value is None:
sys.exit(f"No secret for (service '{SERVICE}', name '{name}').")
sys.stdout.write(value)
else: # del
kr.delete_password(SERVICE, name)
print(f"Deleted secret (service '{SERVICE}', name '{name}').")


if __name__ == "__main__":
_main(sys.argv)
47 changes: 27 additions & 20 deletions scripts/build-and-release.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
#!/usr/bin/env -S uv run --script

# /// script
# dependencies = [
# "fdroidserver",
# ]
# ///
#!/usr/bin/env python3

import subprocess
import json
Expand All @@ -20,6 +14,8 @@
import glob
import argparse

from _keyring import get_secret


# Number of versions to keep in the fdroid repo. Will remove all the older versions.
KEEP_FDROID_VERSIONS = 4
Expand Down Expand Up @@ -100,7 +96,7 @@ def build_releases(project_root: str,

project_root = os.path.dirname(sys.path[0])
build_dir = os.path.join(project_root, 'build')
credentials_file_path = os.path.join(project_root, 'release-creds.toml')
RELEASE_CREDS_KEYRING_NAME = 'release-creds'
fdroid_repo_path = os.path.join(build_dir, 'fdroidrepo')

def detect_android_sdk() -> str:
Expand Down Expand Up @@ -222,10 +218,16 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent
print('Committing the changes...')
subprocess.run(f'git add . && git commit -am "Prepare for release {build.version_name}"', shell=True, check=True, cwd=fdroid_workspace)

# Create Pull Request for releases
# Push the branch explicitly to origin (session-foundation/session-fdroid) first, so
# `gh pr create` has nothing to push and won't prompt. `gh repo clone` adds an `upstream`
# remote (oxen-io), so with two remotes gh otherwise can't decide where to push the head.
print('Pushing the release branch...')
subprocess.run(f'git push -u origin {branch_name} --force-with-lease', shell=True, check=True, cwd=fdroid_workspace)

# Create Pull Request for releases (--head names the already-pushed branch => no prompt)
print('Creating a pull request...')
subprocess.run(f'''\
gh pr create --base master \
gh pr create --base master --head {branch_name} \
--title "Release {build.version_name}" \
-R session-foundation/session-fdroid \
--body "This is an automated release preparation for Release {build.version_name}. Human beings are still required to approve and merge this PR."\
Expand All @@ -248,16 +250,21 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent

# Make sure fdroid command is available
if shutil.which('fdroid') is None:
print('`fdroid` command not found. It is required to automate fdroid releases. Please install it from https://f-droid.org/', file=sys.stderr)
sys.exit(1)

# Make sure credentials file exists
if not os.path.isfile(credentials_file_path):
print(f'Credentials file not found at {credentials_file_path}. You should ask the project maintainer for the file.', file=sys.stderr)
print('`fdroid` command not found. Install fdroidserver via your system package manager:\n'
' Debian/Ubuntu: apt install fdroidserver\n'
' Homebrew: brew install fdroidserver\n'
' MacPorts: port install fdroidserver\n'
'Other methods: https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/',
file=sys.stderr)
sys.exit(1)

with open(credentials_file_path, 'rb') as f:
credentials = tomllib.load(f)
# Load signing credentials (a TOML blob) from the OS keyring, so they never sit
# on disk in plaintext. Store them once with:
# python3 scripts/_keyring.py set release-creds < release-creds.toml
# then delete the plaintext file.
credentials = tomllib.loads(get_secret(
RELEASE_CREDS_KEYRING_NAME,
what='Release signing credentials (release-creds.toml contents)'))

# Make sure build folder exists
if not os.path.isdir(build_dir):
Expand Down Expand Up @@ -304,13 +311,13 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent
# If the a github release draft exists, upload the apks to the release
if not args.build_only:
try:
release_info = json.loads(subprocess.check_output(f'gh release view --json isDraft {play_build_result.version_name}', shell=True, cwd=project_root))
release_info = json.loads(subprocess.check_output(f'gh release view -R session-foundation/session-android --json isDraft {play_build_result.version_name}', shell=True, cwd=project_root))
if release_info['isDraft'] == True:
print(f'Uploading build artifact to the release {play_build_result.version_name} draft...')
files_to_upload = [*play_build_result.apk_paths,
play_build_result.bundle_path,
*huawei_build_result.apk_paths]
upload_commands = ['gh', 'release', 'upload', play_build_result.version_name, '--clobber', *files_to_upload]
upload_commands = ['gh', 'release', 'upload', '-R', 'session-foundation/session-android', play_build_result.version_name, '--clobber', *files_to_upload]
subprocess.run(upload_commands, shell=False, cwd=project_root, check=True)

print('Successfully uploaded these files to the draft release: ')
Expand Down
Loading
Loading