Skip to content

Phase 1: talk to a server and sign in - #1

Merged
AndyScherzinger merged 11 commits into
mainfrom
feature/phase-1-server
Sep 29, 2026
Merged

AndyScherzinger merged 11 commits into
mainfrom
feature/phase-1-server

Conversation

@AndyScherzinger

@AndyScherzinger AndyScherzinger commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Phase 1: the app can talk to a server. A person types a server, the app finds its Mastodon API, whether Nextcloud Social sits at the domain root with the rewrite rules or under its app path without them, and signs in with OAuth (PKCE S256, verified App Link with a custom-scheme fallback). The account is stored with its token in a Keystore-backed vault, and the app learns what the server can do.

  • core:model: the domain model, with ContentClassifier, CharacterCount, TimelineKey and ServerCapabilities.
  • core:network: a plain OkHttp + kotlinx.serialization client with no Retrofit, because every account has its own base.
    • Internal wire DTOs map to domain types, and decoding is lenient and lossy.
    • A Link cursor is followed only on the API base's origin, and dot segments in paths are refused.
    • A read answered 429 with a short Retry-After is retried once.
    • User-trusted certificates are kept per host, and KeyChain client certificates are supported.
  • Server discovery:
    • The RFC 8414 issuer, the domain root, the app path, the pretty app path and a typed path are raced, and the lowest rank that answers wins.
    • NodeInfo serves as the cross-check of the software.
    • A base that stopped answering is re-probed at most hourly.
  • OAuth and capabilities:
    • The app registers once per server.
    • The sign-in in flight is kept in the vault, so it survives process death and never reaches a Bundle.
    • OAuth requests never follow a redirect, and a callback's state is checked before anything else in it.
    • A new account that verify_credentials cannot describe yet falls back to /oauth/userinfo.
    • Capability detection covers the Nextcloud Social extensions, Pixelfed features and the Nextcloud theme.
  • Storage: accounts.db (Room, schemas exported) plus an AES-GCM token vault with an AndroidKeyStore key, StrongBox where the device has it. A lost vault marks accounts needsReauth, and a 401 does the same; nothing is ever deleted.
  • core:testing:
    • The fixture corpus from the dev instance, plus mastodon.social discovery captures.
    • A mock server in seven shapes: with and without the rewrite rules, connected, Mastodon, core-only, rate-limited, and malformed entities.
  • feature:signin:
    • Screens for the terms gate, server entry, probing, the server card, the browser wait, failures and an untrusted certificate.
    • A failure lists what was tried, offers a hand-typed API address, and copies a note for the administrator. The note links Nextcloud Social's contrib/webserver rules instead of embedding them, because those files are AGPL-3.0-or-later.
    • A client-certificate picker sits under Advanced.
    • A release build refuses http:// with a message that says why (https is required), not as an address it can't read.
    • A server nobody answers for (a typo, a server that is down, no network) says it could not be reached, without the note for the administrator, which is kept for servers that answer but serve no Mastodon API.
    • The server card trims the server's texts; mastodon.social ends its description with blank lines.
  • Shorts tab: selection now shows in the icon's shape as on the other tabs (the Slideshow glyph looked the same filled and outlined). This is its own commit because the icon came from the design-system commit on main; a screenshot with Shorts selected guards it.
  • Emulator smoke (ui.yml): it had never completed, so it gets its own commit, separate from the phase's code. The job frees disk space so the emulator can start at all, builds the benchmark APKs before the emulator runs, and keeps a screenshot and view dump when a flow fails.
  • Build: every module's unit and screenshot tests now run in CI through the alohaUnitTests and alohaScreenshotTests aggregates. Before this, CI ran only the app module's flavoured tasks, so the library tests never ran there.

Exported component: OAuthRedirectActivity is exported with an autoVerify App Link filter, because the browser must be able to deliver the OAuth callback. It draws nothing, hands the URI to an in-process inbox and finishes. It has taskAffinity="", so no callback URI travels in an Intent to the main activity.

New dependencies, each with SHA-256 and PGP entries in gradle/verification-metadata.xml:

Dependency Scope
OkHttp 5.5.0 runtime
mockwebserver3 and okhttp-tls test
Room 2.8.5 and its Gradle plugin runtime, build
DataStore 1.2.1 runtime
androidx.browser 1.10.0 (Custom Tabs) runtime
kotlinx-coroutines-test, Turbine, androidx.test core and the JUnit vintage engine test

No new permission.

APK size: the release APK grows from 1.11 MB to 1.99 MB. R8 and resource shrinking are on, and no keep rule is wider than the Navigation keys' serializers. The growth is real code, mostly the model, the networking and serialization, Room and DataStore; OkHttp's public-suffix list alone is 130 KB. config/size-baseline.json moves to the new size in the sign-in commit, where the growth enters the app.

Docs: the "On Android" part of docs/02-server-api.md now covers the client, errors, rate limiting and TLS, how the API base is found, the sign-in rules and the Android differences in capability detection. Each part is in the commit that brought in the behaviour it describes.

Size: about 18.5k lines of Kotlin and XML, far above the thousand-line guideline, plus about 22k lines of fixtures. The phase is one vertical slice whose acceptance is sign-in end to end, so it is split into nine commits that each build on their own and can be reviewed one at a time.

Deferred:

  • OAuthClient.revoke is implemented and tested but unused until a sign-out control exists.
  • The server card's thumbnail waits for the image loader of Phase 2. Nextcloud's thumbnail is also an SVG, which remote content may not use.

Test plan

  • alohaUnitTests is green across all modules, including:
    • sign-in against all seven mock configurations;
    • decoding of every api/ and writes/ fixture through its endpoint factory, with nothing dropped, and a check that fails when a new fixture is left unmapped;
    • ServerFactsTest, which pins the server behaviours the client relies on;
    • account recovery after a lost vault and after a 401;
    • secret redaction in logs and toString.
  • alohaScreenshotTests is green: every sign-in step in light, dark, tablet, 200 % font and right to left.
  • detekt ktlintCheck lint alohaArchitectureCheck is green, and assemble passes for both flavours plus :benchmark:assemble.
  • The build passes with strict dependency verification.
  • Manual run against the WSL dev instance (Nextcloud Social 0.26.97, http://nextcloud.local), through the opt-in LiveInstanceTest. Set ALOHA_LIVE_SERVER, ALOHA_LIVE_USER and ALOHA_LIVE_PASSWORD, then run ./gradlew :core:testing:testDebugUnitTest --tests '*LiveInstanceTest*'.
    • Sign-in succeeds with the root rewrite rules off and with them on, and the API base is found at /index.php/apps/social/.
    • Capabilities are complete (Nextcloud Social, filters v2, stories, collections), and the theme reads #00679e.
    • Exchanging with a wrong verifier gets 401 invalid code_verifier.
    • Plain PKCE gets 400 unsupported code_challenge_method.
    • A made-up token gets 401, which the client maps to Unauthorised.
  • On an emulator (Pixel 10 Pro AVD, Android 37, Play Store image, debug build) against the same instance, reached through a host-side HTTP proxy because Android resolves .local only over multicast DNS:
    • The terms gate, server entry, probe and server card work. The Custom Tab opens Nextcloud's login, then the consent page naming Aloha Social, and the code goes to the custom scheme, since the App Link cannot verify until aloha.social publishes its asset links.
    • The callback reaches OAuthRedirectActivity, which hands it to the app in the same task and leaves no activity behind. One token exchange runs, capabilities are detected, and the shell opens.
    • The app process was killed while the consent page was open; authorising then cold-started the app, which read the attempt back from the vault and finished the sign-in.
    • A cold relaunch stays signed in (the token decrypts from the Keystore after a restart).
    • No code, verifier, token or client secret appears in logcat. The authorize URL (state and PKCE challenge) is logged once by the system launcher's TopTaskTracker at debug level, outside the app's control.
    • Sign-in against mastodon.social also works (manual, emulator, a real account). The account is stored with Mastodon 4.8.0 capabilities: API version 11, grouped notifications, filters v2, translation. The Nextcloud extensions are off.
  • The emulator smoke workflow (ui.yml) passes on this branch (run 36592272873): the Maestro flow on the release builds of both flavours, then the startup macrobenchmark. The flow used to expect the home screen at launch, which this PR moves behind sign-in. It now covers terms, then sign-in, and checks that the terms stay accepted after a restart. It waits out the "System UI isn't responding" dialog that a freshly booted CI emulator shows over the app.

Checklist

  • One concern; split if it approaches a thousand changed lines
  • Tests for every non-trivial branch; fixtures from the dev instance where the server is involved
  • detekt ktlintCheck lint alohaArchitectureCheck green, no baseline grown
  • Screenshots re-recorded only where the UI change is intended
  • Accessibility: labels, 48 dp targets, headings and paneTitle on new screens, 200 % font preview
  • Strings in strings.xml with translator comments
  • No new exported component, permission or dependency without a line here explaining it
  • docs/ updated where behaviour changed
  • AI tools were used for this contribution (commits carry Assisted-by:)

CI asked for testGenericDebugUnitTest and testGplayDebugUnitTest by
name, which only the app module has, so the tests of every library and
JVM module never ran there, the design system's contrast test among
them; verifyRoborazziGenericDebug skipped library screenshots the same
way. Each module now registers alohaUnitTests and alohaScreenshotTests
over whatever its debug variants are called, and CI runs those two.

Android library unit tests run on the JUnit Platform: JUnit 5 for plain
tests, the vintage engine for the JUnit 4 tests Robolectric needs, with
the JDK export Robolectric's file-descriptor interceptor requires. A
skeleton module without tests does not fail the run.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
OkHttp 5 with MockWebServer and okhttp-tls, Room behind a convention
plugin that exports schemas, DataStore, Custom Tabs, Turbine and the
coroutines test library, with their checksums and signing keys. Three
signing keys that no key server serves are listed as ignored, so those
artifacts verify by checksum.

detekt no longer counts early-return guard clauses, treats a flat table
of single-expression when entries as a lookup, and does not count the
members a class overrides from an interface. REUSE covers the fixture
corpus and the Room schemas.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Every entity of the Apple model as an immutable, serialisable domain
type: accounts, statuses and their parts, media, notifications, the
instance, filters, and the Nextcloud Social extensions. Enums keep an
Unknown case and never throw; an unknown visibility fails closed.

Ports ContentClassifier, CharacterCount (grapheme clusters, URLs at the
server's flat rate), TimelineKey and friends, ServerLimits and
ServerCapabilities, which now latch hls_url, reactions and quotes on
first sighting because nothing announces them. Ids compare by length,
then lexically; they never pass through a number.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@github-actions github-actions Bot added the AI assisted Commits carry an Assisted-by trailer label Sep 29, 2026
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Debug APK (generic flavor) for 9862439: https://github.com/AlohaSocial/Android/actions/runs/36604324266/artifacts/11050179993 — kept for 5 days.

Plain OkHttp and kotlinx.serialization, no Retrofit: every account has
its own API base, requests are typed ApiRequest values whose decoder
maps an internal wire DTO to the domain type, and lossy decoding needs
the raw row count. DTOs never leave the module.

Decoding is lenient where servers are: numeric ids, "" and null URLs
(Nextcloud Social sends avatar ""), string booleans, dates in four
shapes, lossy arrays that record what they drop, wrong-shaped objects
as absent. Errors are typed; a 401 is a state, not a failure.

A Link cursor is followed only on the API base's own origin, a path
segment that would resolve as . or .. is refused before anything is
sent, and a read answered 429 with a short Retry-After is retried once;
writes never are. A per-host token bucket is shared by all accounts.

TLS trusts the system first and a certificate the person accepted only
for that exact host; a client certificate comes from the KeyChain.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@AndyScherzinger
AndyScherzinger force-pushed the feature/phase-1-server branch 10 times, most recently from 44dbb5b to 632eed9 Compare September 29, 2026 16:37
Nextcloud Social serves the Mastodon API under its app path and at the
root only where the administrator installed the rewrite rules. The
probe tries the RFC 8414 issuer (accepted only on the typed host), the
domain root, the app path, the pretty app path and a typed path, all at
once; the lowest rank that answers with a non-empty instance domain
wins, and a fast worse answer never beats a slower better one. NodeInfo
is read from its root directory as the cross-check of the software.

An untrusted certificate is reported with its chain so the person can
decide, and an unexpected 404 may trigger a new probe at most hourly.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The 153 exchanges captured from the dev instance, and mastodon.social's
public discovery documents (its contact account replaced by a fictional
one), served by MockWebServer as Nextcloud Social with and without the
rewrite rules, with the Nextcloud connection, as stock Mastodon, as the
Mastodon core only, rate-limited, and with a malformed entity.

Every captured body decodes through the endpoint factory that asks for
it with nothing dropped, and a new fixture fails the build until it is
mapped. The server facts the client works around are pinned, so a
re-capture after a server update shows which workaround to revisit.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
accounts.db holds the accounts and the per-server OAuth registrations,
never a secret. Tokens, client secrets and app passwords live in one
AES-GCM blob under a device-bound AndroidKeyStore key, StrongBox where
available, excluded from backup. A vault that cannot be decrypted, or
accounts restored without their tokens, leave the accounts needing a
new sign-in; nothing is deleted.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Registers the app once per server with both redirect URIs, opens the
authorisation page with PKCE S256 and a state, and exchanges the code
without a scope; a spent registration is forgotten so the next attempt
registers again. The sign-in in flight lives in the vault rather than
in saved state, so it survives the process dying while the browser tab
is open and never reaches a Bundle.

Metadata endpoints on another origin are ignored, OAuth requests never
follow a redirect, and a callback is matched by its state before
anything else in it is read. Nextcloud Social answers 500 from
verify_credentials until a new account's avatar cache job has run, so
such an account is created from /oauth/userinfo with its profile
pending. A 401 on an authenticated request marks the account for a new
sign-in.

Capability detection probes the 4.x routes and the Nextcloud Social
extensions, takes Pixelfed's features document at its word, and reads
the Nextcloud theme from the Nextcloud root. On every launch each
account's API base is checked: one that stopped answering is probed for
anew at most hourly, and capabilities are detected again when the base
moved or they are older than a day.

The screens see only the data layer's own results: what a lookup found,
a certificate to decide on, and why a step failed.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The terms, then the server field, the probe, the server's card and the
browser; failures say what was tried and offer another try, a hand-typed
API address and a note for the administrator. The note links Nextcloud
Social's web-server rules instead of embedding them, because those files
are AGPL-3.0-or-later. An untrusted certificate is shown with its
fingerprint and can be trusted for that server only, and a client
certificate from the system KeyChain can be chosen for it. Status and
errors are announced, the Advanced section says whether it is expanded,
and right to left is recorded as it renders.

The OAuth callback lands on a UI-less activity that hands it to an
in-process inbox and brings the app forward, so no callback URI travels
in an Intent. The verified App Link is used where this install is
verified, the custom scheme otherwise. The shell shows a banner when an
account needs a new sign-in; nothing is lost.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The emulator smoke had never completed. After the release builds the
hosted runner had 3.6 GB of disk left, and the emulator refuses to start
without 7.4 GB for its userdata partition; the job now removes the
preinstalled .NET, GHC and CodeQL toolchains, which it never uses.

With the emulator up, the macrobenchmark step compiled the benchmark
variant with R8 while the emulator was running, and the runner stopped
responding. The benchmark APKs are now built before the emulator starts,
so the connected run only installs and measures.

A failed flow now leaves the screen and its view hierarchy as an
artifact; that is how "System UI isn't responding" over the app showed
up as the cause of the flow failing on its first screen.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Every destination carries its selection in shape as well as colour,
outlined at rest and filled when selected. Material's Slideshow glyph
draws the same frame in both variants, so a selected Shorts tab still
looked outlined. Shorts now uses AmpStories, stacked portrait cards like
the Apple app's symbol, whose filled variant is a solid card.

A screenshot with Shorts selected keeps the bar from regressing.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@AndyScherzinger
AndyScherzinger merged commit ca12f80 into main Sep 29, 2026
7 checks passed
@AndyScherzinger AndyScherzinger added this to the 1.0.0 milestone Sep 29, 2026
@AndyScherzinger
AndyScherzinger deleted the feature/phase-1-server branch September 29, 2026 17:58
@AndyScherzinger AndyScherzinger added the enhancement New feature or request label Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI assisted Commits carry an Assisted-by trailer enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant