Mobile App Platform
The server-side foundation for native apps (iOS and Android) on any Joinery
deployment. An app is a native shell — login, account forms, navigation,
settings — around the authenticated web surface: every /profile page renders
inside the app through a chrome-less webview, and any surface can later be
promoted to a native screen without auth or navigation rework.
Three server pieces make this work, all client-agnostic:
- Navigation endpoint —
GET /api/v1/app/navigationreturns the user's menu as a routing table for the app's tab bar and More list. - Web-session bridge —
POST /api/v1/auth/web_session+/app_bridgeturn the app's API session key into a web session for the webview, with no second login. - App display mode — bridged web sessions render pages without site chrome (header, navigation, footer).
docs/api.md § Auth Endpoints), actions, and form JSON
(docs/formwriter.md § JSON Output Mode).Navigation endpoint
GET /api/v1/app/navigation — requires an app session key (machine keys
and browser sessions get 403). Served by includes/ApiAppEndpoint.php.
The source is the seeded profile menu store (amu_admin_menus, location
user_dropdown) — the same store every web theme renders — so adding a core
entry in admin_menus.json or a plugin profileMenu entry makes it appear in
every shipped app with no release. Standard store filters apply: the entry's
permission vs the user's, visibility (app users are always signed in),
settingActivate, and disable. Entries the native shell owns (sign in/out,
sign up, forgot password) are excluded.
Response shape:
{
"data": {
"tabs": ["core-profile", "core-calendar", "mailbox"],
"entries": [
{
"slug": "core-profile",
"title": "My Profile",
"icon": "user",
"order": 50,
"destination": { "type": "web", "url": "/profile" }
}
]
}
}entries— every menu entry this user gets, ordered. Each has adestinationthe client resolves version-safely:{type: "web", url}renders in the authenticated webview; `{type: "native", screen, fallback_url}` renders the named native screen when the shipped client recognizesscreen, otherwise loadsfallback_url. Promoting a surface to native is a destination change — old app versions keep working through the fallback.
nativeScreen key on the
entry in admin_menus.json or the plugin's plugin.json profileMenu
(stored as amu_native_screen by the menu sync). A non-empty value flips
that entry's destination to {type: "native"} with the entry's URL as the
fallback. Worked examples: the mailbox plugin's mailbox entry
declares "nativeScreen": "mailbox" (rendered by JoineryMailKit), the core
core-calendar entry in admin_menus.json declares `"nativeScreen":
"calendar"` (rendered by JoineryCalendarKit), and the joinery_ai plugin's AI
Chat entry declares "nativeScreen": "ai_chat" (rendered by
JoineryAIChatKit); every other client keeps the web page.
tabs— the slugs pinned to this app's tab bar, in order; everything else belongs in the More list. Configured by theapp_navigationsetting: a JSON map ofclient_app(the client header value) → ordered slug list, with adefaultkey for apps without their own entry. Slugs the user did not receive are dropped.
Web-session bridge
The app authenticates once (POST /api/v1/auth/login) and holds a session
key. When a webview needs the web surface, the app derives a web session from
that key — the user never sees a web login page.
Minting: POST /api/v1/auth/web_session with the session key. Body:
{"target": "/profile/calendar"} — a same-origin relative path (absolute
URLs, protocol-relative //host forms, backslashes, and raw whitespace are
rejected 400; omitted defaults to /). Session keys only: browser sessions
already are a web session (403), and machine keys are integration
credentials, not devices (403). Returns:
{ "data": { "bridge_url": "/app_bridge?token=…64 hex…", "expires_in": 60 } }Consumption: the webview loads bridge_url. /app_bridge
(logic/app_bridge_logic.php) claims the token atomically — single-use by
construction — verifies the originating key is still live, starts a normal
web session for the key's user (session id regenerated), marks it
app-context with the originating apk_api_keys id and client_app, and
302s to the target. Invalid, used, or expired tokens render an "expired link"
page (HTTP 410); the app's recovery is minting a fresh token.
Tokens live in abt_app_bridge_tokens (data/app_bridge_tokens_class.php):
60-second TTL, stored as SHA-256 hashes, purged opportunistically on mint.
The plaintext exists only in the mint response and is never logged.
Lifetime coupling: an app-context web session is valid only while its
originating API key is. SessionControl re-verifies the key (throttled to
app_bridge_key_check_seconds, default 60; 0 = every request) and ends the
session when the key is revoked, expired, or gone. Revoking the key — app
logout, the App Sessions page at /profile/security, or a password change —
therefore kills both the app's API access and its webview session in one
gesture.
App behavior: bridge on first webview use; the webview cookie store persists the session across launches; on detecting a logged-out response the shell silently re-bridges and retries once before surfacing anything.
App display mode
App-context sessions render pages without the site header, navigation, and
footer — page content and the jy-ui design system only. The native shell
supplies titles and back navigation.
The decision lives in one place: PublicPageBase::show_site_chrome() (false
for app-context sessions). Every theme wraps its chrome markup in it:
<?php if ($this->show_site_chrome()): ?>
<nav class="site-nav">…</nav>
<?php endif; ?>Only the visual chrome is conditional — stylesheets, scripts, and content
wrappers stay unconditional. PublicPageBase also adds a jy-app-mode body
class as a CSS hook and suppresses the admin bar for app-context sessions.
Themes must apply the same wrap to their header and footer blocks; the check
is a constant true for all ordinary traffic, so wrapped chrome renders
byte-identically outside app mode.
Settings
| Setting | Default | Purpose |
|---|---|---|
app_navigation | {"default": ["core-profile", "core-calendar", "mailbox"]} | Per-app tab-bar pinning (JSON map of client_app → ordered slugs) |
app_bridge_key_check_seconds | 60 | How often a bridged session re-verifies its originating key |
api_min_client_versions | {} | Per-app minimum client versions (HTTP 426 gate, docs/api.md § Client Versioning) |
api_session_key_lifetime_days | 365 | Session key expiry |
JoineryKit (iOS client core)
The native iOS core is JoineryKit, a standalone Swift package with no
brand knowledge — every branded app is a thin target that injects a
JoineryConfig and mounts JoineryAppRoot. The source lives in this repo
at {repo root}/ios/ (outside public_html, so it is never web-served or
packaged into node upgrades): ios/joinery-kit is the package,
ios/joinery-member-ios the reference app (XcodeGen project.yml,
regenerate with xcodegen generate). Builds run on the Mac mini
(ssh macmini) against a synced build area at ~/dev/joinery-ios — the
gate runners rsync ios/ there before building; the repo checkout on the
dev box is the single source of truth.
What the kit provides:
- APIClient — the one HTTP chokepoint: key headers,
client-app/client-versionheaders (hyphen form), automaticIdempotency-Keyon action submits, and error-envelope mapping onto a typedJoineryAPIError. API JSON is parsed with an order-preserving parser (JSONValue) because formoptionsorder is meaningful and Foundation's decoders lose it. - SessionController + KeychainStore — login (
auth/loginwith adevice_label), Keychain-only secret storage, launch bootstrap viaauth/session, logout (auth/logout), and global handling of two cross-cutting signals: any 426 flips the app into the blockingUpgradeRequiredView, and a 401 on an authenticated call (key revoked — App Sessions page or password change) signs the app out. - Generic form renderer —
FormScreenfetchesGET /api/v1/form/{action}, renders every schema-v1 field type (docs/formwriter.md§ JSON Output Mode) includingvisibility_ruleswith web-identical trigger semantics, submits toPOST /api/v1/action/{action}, and maps 422 field errors back onto controls. A definition with an unknown field type or a newerschema_versionrenders a per-form "update the app or use the website" fallback, so old binaries survive new server-side field types. - Navigation shell —
NavigationShellrenders the signed-in surface fromGET /api/v1/app/navigation: server-pinned entries fill the tab bar (at most four; the last slot is always More), everything else plus the native Settings screen lands in the More list. Destinations resolve version-safely (NavDestination):webrenders in the authenticated webview;nativerenders the named screen when the build recognizes it and itsfallback_urlotherwise. Screen names resolve against the kit's own screens first (settings), thenNativeScreenRegistry— the table layered modules add their screens to at app launch (JoineryMail.registerScreens()registersmailbox). Server icon names map onto SF Symbols with a neutral fallback. The shell refreshes the user summary and the navigation table on every foreground, so menu changes appear and a session revoked from the web signs the app out without a relaunch. - Webview component —
WebScreenis how every web destination renders, implementing the whole webview contract in one place (WebSessionCoordinator+WebScreen): first use mints a bridge URL (POST /api/v1/auth/web_session) and drives the webview through it; the bridged session lives in the shared persistent cookie store, so later screens and later launches load their targets directly. A/loginredirect (bridged session gone) or an expired bridge token (410) triggers one silent re-bridge back to the intended path; a dead API key makes the re-bridge mint 401, which signs out the whole app. Link policy: same-origin navigations stay in-webview, off-site links and non-web schemes leave the app (Safari, Mail), subframes are untouched. The native bar shows the page title and a history-back chevron; pull-to-refresh, file uploads (WKWebView native pickers), downloads (share-sheet hand-off), JS alert/confirm/prompt as native panels, and loading/error/offline-retry states are built in. The webview stays inside the safe area — extending under the native tab bar would leave fixed-bottom web UI (cookie consent, sticky action bars) visible but untappable behind it. Sign-out clears the site's cookies from the shared store (theSessionController.onSignOuthook). - Native screens —
LoginView, the two-step native password-reset flow (password_reset_1, code entry from the emailed link,password_reset_2with the code round-tripped via query context),SettingsView(user/tier summary fromauth/session, the account forms, the App Sessions page as a webview destination, sign out), andUpgradeRequiredView.
JoineryConfig): baseURL, clientApp,
clientVersion, appName, appStoreURL, registrationEnabled (off by
default — enabling in-app registration triggers Apple's account-deletion
requirement), accentColor.Control accessibility identifiers are stable API for UI tests: form controls
use their server field names, and screens use prefixed ids
(login_*, settings_*, form_*, reset_*, upgrade_*, nav_*,
web_*, mail_*, and more_{slug} for More-list rows).
JoineryMailKit (native mail module)
JoineryMailKit is the first native content surface — the same package
repo, second product (ios/joinery-kit/Sources/JoineryMailKit, depends on
JoineryKit). An app adds the product and calls JoineryMail.registerScreens()
in its init; the server's mailbox navigation destination then renders these
screens, and builds without the module keep the web reader via the fallback
URL.
The screens consume the mailbox/* actions
(plugins/mailbox/docs/overview.md § API Surface) — the same
MailboxService brain as the web reader, so every read/star/archive/spam
change is immediately visible in both:
- Mailbox screen (
MailboxScreen+MailboxStore) — Gmail-style thread list: colored-initial avatars, bold unread rows, star toggles, snippet lines; views (Inbox / Starred / All Mail / Spam) and a mailbox switcher in the toolbar menu; server-side search (.searchable, submit-to-search); swipe actions (archive full-swipe, read/unread toggle); pull-to-refresh and page-on-scroll (50/page). A toolbar compose button (square.and.pencil, disabled unlesscan_compose) opensComposeSheetin new-message mode. - Thread view (
ThreadDetailView+MessageCardView) — subject header, message cards with older messages collapsed; opening marks the thread read (the reader's explicitmark_read); HTML bodies render in a sandboxedWKWebView(content JavaScript off, link taps open externally, height self-measured); plain bodies render as native text; inline images arrive as signed URLs already rewritten server-side; attachment chips download via their signed URL into the share sheet. Toolbar: archive, mark unread, spam/not-spam, delete. - Compose (
ComposeSheet) — reply / reply-all / forward / new message with To/Cc/Subject and body. Deliberately lean: for reply/forward the server quotes the original, normalizes the subject, applies threading headers, and resolves the sending identity from the source message; for a new message there is no source, so the sheet shows a FromPickerover the granted mailboxes (MailboxStore.home.mailboxes, preselectingselectedAlias) and the server takes the pickedalias_idas the identity instead, with no quoted-original footer text. An attach menu offers Photo Library (HEIC transcoded to JPEG) and Files (any type, no allowlist — matching the server's policy); picked files show as removable chips. An attachments-only send goes out asmultipart/form-datatomailbox/send(APIClient.submitMultipart, fieldattachments[]), otherwise the plain JSON action — the sameChatAPI.send()shape the AI chat composer uses. Client-side preflight mirrors the server's caps (10 files, 10 MB per file, 25 MB total) but the server is the authority; a rejected send keeps the draft and attachments in the sheet. Forwards still re-attach the original's attachments server-side; a sent copy with new uploads shows them in the thread view once the manifest persists — no separate rendering path. - Labels/move picker (
FolderPickerSheet, opened from a thread'smail_folderstoolbar button) — the web reader'sbuildFolderControl()exclusive-vs-non-exclusive model as a native sheet: an exclusive feed (an IMAP mailbox where a message lives in exactly one folder) gets a single-pick "Move to" that relocates the thread on selection; a non-exclusive feed (Gmail-style custom labels) gets checkbox "Labels" that toggle membership independently. Both end with a create-folder/label row. All three operations ride the existingmailbox/thread_action(set_membership,create_folder) — no server change. The button is hidden entirely when the thread's mailbox has no tracked folders yet, matching the web and Android pickers exactly (there is no empty-state affordance to create the first one from the button itself).
JoineryCalendarKit (native calendar module)
JoineryCalendarKit is the native personal-calendar surface — same
package repo, third product (ios/joinery-kit/Sources/JoineryCalendarKit,
depends on JoineryKit). An app adds the product and calls
JoineryCalendar.registerScreens() in its init; the server's calendar
navigation destination then renders these screens.
The screens consume the core calendar actions (docs/calendar.md § API
surface) — the same aggregation and write path as /profile/calendar, so
every entry is immediately visible on both:
- Calendar screen (
CalendarScreen+CalendarStore) — a month grid (leading-blank cells honoring the device's first weekday, colored event dots per day, today ring, selected-day fill) over a selected-day agenda; chevrons and horizontal swipe change months, "Today" returns; each month fetches its window padded a week per side. Items group onto local days in the device timezone (CalDisplay.dayKeys), all-day items first. - Agenda rows — native entries open the entry editor; projected items
(events, bookings) show their type chip and push their web page in the
authenticated webview (
WebScreen) — the calendar never edits what it doesn't own. - Entry editor (
EntryEditorSheet) — title, date, all-day or timed, block-availability toggle, and the full recurrence surface (frequency, interval, weekly day chips, monthly same-day / ordinal-weekday, ends never / on date / after N). Recurring edits and deletes ask for scope (this / future / all) exactly like the web modals; a "this occurrence only" save sends no recurrence (the series keeps its own). Wall-clock values go up with the entry's stored timezone on edit, the device zone on create; the server does all timezone math.
cal_*) are the stable UI-test API: cal_loading,
cal_error, cal_retry, cal_grid, cal_month_title, cal_prev_month,
cal_next_month, cal_today, cal_add, cal_agenda, cal_selected_day,
cal_agenda_empty, and in the editor cal_entry_title, cal_entry_date,
cal_entry_allday, cal_entry_start, cal_entry_end, cal_entry_blocks,
cal_entry_repeats, cal_entry_save, cal_entry_cancel,
cal_entry_delete, cal_entry_error. (JoineryMailKit's mail_* ids follow
the same convention.)Not in the module (the web calendar remains for them): .ics import and
the quick-entry popover's time-prefix parsing.
JoineryAIChatKit (native AI chat module)
JoineryAIChatKit is the native AI-assistant surface — same package repo,
fourth product (ios/joinery-kit/Sources/JoineryAIChatKit, depends on
JoineryKit). An app adds the product and calls
JoineryAIChat.registerScreens() in its init; the server's ai_chat
navigation destination then renders these screens, and builds without the
module keep the web chat via the fallback URL.
The screens consume the joinery_ai/chat_* actions
(plugins/joinery_ai/docs/overview.md § Chat API surface) — the same
conversation store and turn engine as the web chat, so a
conversation opened natively and one opened on the web are the same thread:
- Conversation list (
ChatScreen+ChatListStore) — the caller's conversations pinned-first then newest, with server-side search, swipe and context-menu pin / rename / delete, and a compose button that opens a new chat. Reaching the native screen is proof the server flipped the entry to{type: "native", screen: "ai_chat"}; an older build loads the web chat. - Thread + composer (
ChatThreadView+ChatThreadStore) — the turns in a scroll (user bubbles right, assistant markdown left), a bottom composer, and per-turn / conversation token-cost labels. A running turn streams via the same send-then-poll transport the web reader uses:chat_sendreturns a poll handle, the store pollschat_poll(~600ms) folding partial text into the row until it settles; the poll'sactivity+running_secondsrender as a live status line on the running turn ("Waiting for glm-5p2… · 2m 40s"). A turn that proposes a mutating action shows an inline Confirm / Cancel card resolved throughchat_confirm. Turn delete and a lightweight block-markdown renderer (headings, bullets, rules, inline emphasis) round it out. - Settings sheet (
ChatSettingsSheet, gear toolbar button) — the per-chat controls: a model picker (catalog fromchat_controls, with a not-private warning), Data access / Web search toggles, a thinking-level picker, and the sampling fields (temperature, top-p, max tokens) plus custom instructions. Picker and toggle edits persist immediately viachat_set_capabilities; a new chat carries its control values as seed fields on the firstchat_send. A new chat defaults Data access on so the assistant is useful out of the box; the rest take the server's new-chat defaults. - Attachments (
ChatThreadViewpicker +AttachmentsView) — the composer's attach button offers Photo Library (PhotosPicker) and Files (.fileImporter, accepting PDF, text, CSV, JSON, Markdown, and images). Picked files show as removable chips and send asmultipart/form-datatochat_send(APIClient.submitMultipart, fieldattachments[]); an attachments-only turn needs no message text. HEIC photos transcode to JPEG client-side so the server's byte-detected type lands in its allowed set. The server owns all validation — type, size, and the model's vision/document capability — and is the sole authority: a rejection surfaces its message, and a file dropped at commit for type drift comes back asattachment_warning, shown above the composer. Sent and historical attachments render on the user bubble — images as a thumbnail from the signedimage_urlthe serializer mints (File::mintSignedUrl, 5-minute TTL, so a cookieless native client loads it), everything else as a labeled file chip.
chat_*) are the stable UI-test API: chat_loading,
chat_error, chat_retry, chat_list, chat_empty, chat_new,
chat_thread_loading, chat_thread_empty, chat_transcript,
chat_user_message, chat_assistant_message, chat_typing,
chat_activity, chat_confirm_card, chat_confirm_yes, chat_confirm_no, chat_composer,
chat_send, chat_attach, chat_attachment_strip, chat_attachment_remove,
chat_attachment_notice, chat_attachment_image, chat_attachment_chip,
chat_settings, chat_set_model, chat_set_data_access,
chat_set_web_search, chat_set_thinking, chat_settings_done.Not in the module yet (the web chat remains for it): thread export.
JoineryMemberKit (native member surface module)
JoineryMemberKit is the native member-account surface — same package
repo, fifth product (ios/joinery-kit/Sources/JoineryMemberKit, depends on
JoineryKit). An app adds the product and calls
JoineryMember.registerScreens() in its init; the server's profile,
orders, subscriptions, events, conversations, and security
navigation destinations then render these screens, and builds without the
module keep the web pages via each entry's fallback URL. conversations and
security are not menu entries — they are reached from the dashboard and
from Settings respectively, both resolved through the same
NativeScreenRegistry table so a JoineryKit-only build (no JoineryMemberKit)
falls back to the equivalent webview destination.
The screens consume seven purpose-built read actions and reuse existing
mutation actions rather than adding new ones — profile_dashboard,
order_list, subscription_summary, my_events, conversation_list,
conversation_thread, security_overview for reads;
orders_recurring_action, event_withdraw, security, address_edit,
phone_numbers_edit, conversation_send, and conversation_action for
mutations:
- Dashboard (
ProfileScreen+ProfileStore, registry nameprofile) — the user card, a needs-attention row (pending event surveys, unread message count), stat tiles, and recent-item lists (events, conversations, orders, subscriptions). Every section renders strictly from keys theprofile_dashboardpayload actually sent — a settings-gated section (messaging_active/products_active/subscriptions_activeoff) is an absent key, not an empty placeholder. Tiles and rows navigate to the other native screens directly; Notifications and event survey links open throughcontext.web. - Orders (
OrdersScreen+OrderListStore, registry nameorders) — a paginated, read-only order history (10/page) with each order's line item summaries. - Subscriptions (
SubscriptionsScreen+SubscriptionStore, registry namesubscriptions) — active and cancelled subscriptions, current tier, and Cancel (a confirmation alert intoorders_recurring_action, which cancels at period end unconditionally once called — the confirmation is purely client-side, matching the web flow). Change Plan and Manage Billing open the web pages throughcontext.web— deliberately: Apple IAP policy means purchase/upgrade UI cannot be rebuilt natively around Stripe untilspecs/mobile_app_billing.mdlands, and the Stripe Billing Portal is a hosted web flow regardless. - Events (
EventsScreen+EventListStore, registry nameevents) — a status-tabbed (all / active / expired / canceled / completed), paginated (10/page) registration list. Rows open the session content page (video embeds, CMS content) throughcontext.web— deliberately, since a native rendering would embed a webview for the video anyway. Withdraw is a confirmation alert into the existingevent_withdrawaction. - Conversations (
ConversationsScreen+ConversationListStorefor the inbox,ConversationThreadView+ConversationThreadStorefor one thread, registry nameconversations) — a paginated (20/page) inbox with mute/unmute/delete, opening into a bubble thread with cursor-paginated messages (50/page,before/afterISO cursors) and a compose bar. Opening a thread marks it read as a side effect of theconversation_threadcall, exactly as the web conversation page does — both ride the same actions, so read state and messages are shared in real time. Ato-addressed thread dedups to an existing 1:1 conversation server-side (is_compose_modein the response) or creates one on first send. - Security (
SecurityScreen+SecurityStore, registry namesecurity) — the app-session list (per-row and revoke-all, via thesecurityaction'srevoke_app_session/revoke_all_app_sessions) and TOTP status with enable / confirm / disable / regenerate flows (start_enable,confirm_enable,cancel_enable,regenerate_backup_codes,disable). The enable flow renders theprovisioning_uri(otpauth://…) as a QR natively withCIFilter.qrCodeGenerator— no SVG parsing needed, since the server already returns the same URI the web page's SVG QR encodes. Revoking the session key that made the request signs the app out through the kit's existing 401 sign-out path (SessionController.logout()). Passkeys and the Sealed Vault are stated as web-managed here — WKWebView cannot expose platform WebAuthn, so native passkey/vault management is a separate future spec (specs/mobile_native_member_screens.md§ Deliberately web).
security action predates the purpose-built-payload pattern: most of its
mutating branches redirect server-side with an empty data: {} on both
success and failure (the web page tells them apart with a flash message a
native client can't read). SecurityAPI re-reads security_overview after
disable to confirm the outcome rather than trusting the envelope alone —
see ios/joinery-kit/Sources/JoineryMemberKit/SecurityAPI.swift.Settings additions (JoineryKit core, not this module — no new module needed
since the generic form renderer already covers them): SettingsView gained
FormScreen rows for address_edit and phone_numbers_edit (matching the
existing account_edit row), and its Security row resolves through
NativeScreenRegistry.view(for: "security", …), falling back to the
/profile/security webview when JoineryMemberKit isn't linked.
Accessibility ids follow each screen's own prefix: profile_*, orders_*,
subscriptions_*, events_*, conversations_* / conversation_*,
security_*. settings_security, settings_address_edit, and
settings_phone_numbers_edit are the new Settings rows.
JoineryDNSFilterKit (native DNS-filtering module)
JoineryDNSFilterKit is the native DNS-filtering surface — same package
repo, sixth product (ios/joinery-kit/Sources/JoineryDNSFilterKit, depends on
JoineryKit). It is brand-neutral: any ScrollDaddy-style deployment (ScrollDaddy
today, a future NetworkSentry with only branding changed) reuses it. The
ScrollDaddy app (ios/scrolldaddy-ios) is the reference consumer; it calls
JoineryDNSFilter.registerScreens(config:) in its init, passing a
DNSFilterConfig (deployment origin, brand name, packet-tunnel bundle id) so
the kit carries no brand knowledge.
Two navigation destinations resolve to native screens; the
/profile/dns_filtering/* webviews are the version-skew fallback for builds
that don't link the module (nativeScreen on the dns_filtering plugin's
profileMenu entries):
- Protection (
ProtectionScreen+ProtectionStore, registry namedns_protection) — the app's home. Resolves this phone's device row (pinned locally per deployment viaPhoneDeviceStore), guides the one-time registration and the standard-mode activation, and offers the single Standard / Strict protection-level control. It reads thedns_filtering/devicesandaccount_summaryactions. - Devices (
DevicesScreen+DeviceListStore, registry namedns_devices) — every device on the account, each opening its always-on policy editor. Editing policy is shared with the web devices page (same actions), while applying configuration is this-phone-only (the Protection screen). - Always-On editor (
AlwaysOnEditorScreen+BlockEditorStore, reached from both screens, not a menu entry) — general category filters (the free floor), tier-gated advanced filters, service toggles, and custom domain rules. Every toggle is save-on-change throughblock_filter_set; "off" submits as removing the row (Allow = no row), keeping the resolver-merge invariant server-side. Custom rules add/delete throughblock_rule_add/block_rule_delete, and offer the hard-block flag (Strict mode) — the server re-enforces the always-on/block-action constraint. Tier gates render as locked/upsell states from theaccount_summaryfeature flags, but the server rejects gated writes regardless.
DNSFilterAPI) and models (DNSDevice, DNSBlockContents,
DNSCatalog, DNSAccountSummary) sit over the dns_filtering/ action
namespace — no new server work beyond what the plugin already exposes.Config delivery — NEDNSSettingsManager. Standard mode saves the device's
doh_url as a system-wide encrypted-DNS (DoH) configuration
(DNSActivationManager), the same mechanism NextDNS/AdGuard use. iOS layers it
over the network's DNS non-destructively — disabling, removing, or uninstalling
reverts losslessly, so there is nothing to save or restore. The one manual step
is the OS security gate (enable once in Settings → General → VPN, DNS & Device
Management → DNS); the app detects isEnabled and shows a guided one-tap step.
The DNS Settings entitlement requires a paid Apple Developer Program membership.
Strict mode — packet tunnel (Phase 4). An on-device
NEPacketTunnelProvider (in the app's ScrollDaddyTunnel extension target)
adds connection-level hard blocking: in-tunnel DNS forwards to the deployment's
DoH resolver, and each new TLS flow's SNI is matched against the synced
hard-block list — a connection to a blocked host is dropped even when the app
resolved the IP through its own hardcoded DoH. The security-critical inspection
(HardBlockList, TLSClientHello) lives in the kit and is unit-tested
off-device (Tests/JoineryDNSFilterKitTests); the extension owns only the
NetworkExtension plumbing. StrictModeManager installs/starts the tunnel and
pushes hard-block list changes live. One VPN at a time — the manager detects a
foreign active VPN and surfaces the conflict rather than failing silently.
Accessibility ids: protection_*, devices_* / device_*, editor_*,
filter_*, service_*, rule_*. Billing is login-only at launch
(registrationEnabled false); see specs/implemented/scrolldaddy_ios_app.md
for the phasing.
JoineryBillingKit (native billing module)
JoineryBillingKit is the native in-app purchase surface
(ios/joinery-kit/Sources/JoineryBillingKit, depends on JoineryKit only). An
app that sells subscriptions adds the product and calls
JoineryBilling.registerScreens() at launch; a menu entry with
nativeScreen: "billing" lights it up, with the web pricing page as the
version-skew fallback.
- BillingScreen (registers
billing) — current plan (server-authoritative, fromstore/subscription_summary), purchasable plans fromstore/billing_catalogwith StoreKit-localized prices, the StoreKit 2 purchase sheet, restore purchases, and manage-routing by source (App Store deep link, or a notice when the subscription is billed elsewhere — source exclusivity).
jwsRepresentation) to
store/app_store_claim; the server verifies and grants the tier before the
UI reports success, and the purchase carries the catalog's
app_account_token as appAccountToken for webhook-side user linkage. The
server model, webhooks, and claim actions: docs/mobile_billing.md.Accessibility ids: billing_*. Parsing tests with fixture envelopes:
Tests/JoineryBillingKitTests.
joinery-android (Android client core)
The native Android core is joinery-android, a Kotlin + Jetpack Compose
library with no brand knowledge — every branded app is a thin module that
injects a JoineryConfig and mounts JoineryAppRoot. It is the Android
counterpart to JoineryKit against the same client-agnostic server surface
(navigation endpoint, web-session bridge, app display mode); the reusable
boundary between the platforms is that server API, not shared client code.
The source lives in this repo at {repo root}/android/ (outside
public_html, so it is never web-served or packaged into node upgrades):
android/joinery-android is the library module, android/joinery-member-android
the reference app. Builds run on the Mac mini (ssh macmini) against a synced
build area at ~/dev/joinery-android — ./gradlew/gradle with the SDK from
~/.android-env, headless emulator, screenshots via
adb exec-out screencap; the repo checkout on the dev box is the single
source of truth.
What the library provides:
- ApiClient — the one HTTP chokepoint (OkHttp): key headers,
client-app/client-versionheaders (hyphen form), automaticIdempotency-Keyon action submits, and error-envelope mapping onto a typedJoineryApiError. API JSON is parsed with an order-preserving parser (JsonValue) because formoptionsorder is meaningful and stock decoders lose it. - SessionController + EncryptedCredentialStore — login (
auth/loginwith adevice_label), Keystore-backedEncryptedSharedPreferencessecret storage (never plain SharedPreferences or files), launch bootstrap viaauth/session, logout (auth/logout), and the two cross-cutting signals: any 426 flips the app into the blocking upgrade screen, and a 401 on an authenticated call (key revoked — App Sessions page or password change) signs the app out. - Generic form renderer —
FormScreenfetchesGET /api/v1/form/{action}, renders every schema-v1 field type (docs/formwriter.md§ JSON Output Mode) includingvisibility_ruleswith web-identical trigger semantics, submits toPOST /api/v1/action/{action}, and maps 422 field errors back onto controls. A definition with an unknown field type or a newerschema_versionrenders a per-form "update the app or use the website" fallback, so old binaries survive new server-side field types. - Navigation shell —
NavigationShellrenders the signed-in surface fromGET /api/v1/app/navigation: server-pinned entries fill the bottom bar (at most four; the last slot is always More), everything else plus the native Settings screen lands in the More list. Destinations resolve version-safely (NavDestination):webrenders in the authenticated webview;nativerenders the named screen when the build recognizes it and itsfallback_urlotherwise. Screen names resolve against the library's own screens first (settings), thenNativeScreenRegistry— the table layered modules add their screens to at app launch. Server icon names map onto Material icons with a neutral fallback. The shell refreshes the user summary and the navigation table on every foreground, so menu changes appear and a session revoked from the web signs the app out without a relaunch. - Webview component —
WebScreenis how every web destination renders, implementing the whole webview contract in one place (WebSessionCoordinator+WebScreen): first use mints a bridge URL (POST /api/v1/auth/web_session) and drives the WebView through it; the bridged session lives in the process-wide WebView cookie store, so later screens and later launches load their targets directly. A/loginredirect (bridged session gone) or an expired bridge token (410) triggers one silent re-bridge back to the intended path; a dead API key makes the re-bridge mint 401, which signs out the whole app. Link policy: same-origin navigations stay in-webview, off-site links open in Custom Tabs and non-web schemes (mailto, tel) leave the app, subframes are untouched. The native app bar shows the page title; system back walks webview history before the shell's navigation. Pull-to-refresh, file uploads (SAF picker), downloads (DownloadManager, carrying the bridged cookie), and loading/error/offline-retry states are built in. Sign-out clears the site's cookies and web storage (theSessionController.onSignOuthook). - Native screens —
LoginScreen, the two-step native password-reset flow (password_reset_1, code entry from the emailed link,password_reset_2with the code round-tripped via query context),SettingsScreen(user/tier summary fromauth/session, the account forms, the App Sessions page as a webview destination, sign out), andUpgradeRequiredScreen.
JoineryConfig): baseUrl, clientApp,
clientVersion, appName, playStoreUrl, registrationEnabled (off by
default — enabling in-app registration triggers Google Play's
account-deletion requirement), accentColor.Compose testTag values equal the server field names for form controls and use
prefixed ids for screens (login_*, settings_*, form_*, reset_*,
upgrade_*, root_*, nav_*, web_*, and more_{slug} for More-list rows) —
the stable addressing for the Compose UI-test suite.
joinery-android-mail (native mail module)
joinery-android-mail is the Android native mail surface — a second library
module in the Android workspace (android/joinery-android-mail, depends on
:joinery-android), the platform counterpart to JoineryMailKit. An app adds
the module and calls JoineryMail.registerScreens() before mounting the root;
the server's mailbox navigation destination then renders these screens, and
builds without the module keep the web reader via the fallback URL.
The screens consume the same mailbox API actions as JoineryMailKit
(plugins/mailbox/docs/overview.md § API Surface) and mirror its UX;
the platforms share fixtures (the same captured envelopes back both test
suites) but no client code:
- Mailbox screen (
MailboxScreen+MailboxStore) — Gmail-style thread list: colored-initial avatars, bold unread rows, star toggles, snippet lines; a top-bar filter menu with views (Inbox / Starred / All Mail / Spam), a mailbox switcher (when more than one grant), and the mailbox's folder/label rail (Mailbox.folders— selecting one scopes the list viathread_list'sfolder_id); a search field (submit-to-search, clear-to-reset); swipe actions (SwipeToDismissBox: read/unread toggle and archive); pull-to-refresh, refresh-on-foreground, and page-on-scroll (50/page). A top-bar compose button (disabled unlesscan_compose) opensComposeSheetin new-message mode. - Thread view (
ThreadDetailScreen+MessageCard) — subject header, message cards with older messages collapsed; opening marks the thread read (the reader's explicitmark_read); HTML bodies render in a sandboxedWebView(JavaScript off, link taps open externally, height self-measured); plain bodies render as native selectable text; inline images arrive as signed URLs already rewritten server-side; attachment chips download via their signed URL into the system viewer/share sheet (module-declaredFileProvider, cache-scoped). Top bar: archive, the Move/Labels control, mark unread, spam/not-spam, delete. - Move/Labels picker (
FolderSheet, on the open thread) — the web reader's folder control as a bottom sheet, keyed off the thread's own mailbox (resolved from the messages'alias_id, so it works in the all-mailboxes list). An exclusive feed gets single-pick "Move to" radio rows (choosing one relocates the thread and closes it); a non-exclusive feed (Gmail) gets "Labels" checkboxes (toggling callsset_membershipper change). Both end with a create row (create_folder, which also files the thread into the new folder). Current membership comes from thethreadaction'sfoldersids. - Compose (
ComposeSheet) — reply / reply-all / forward / new message with To/Cc/Subject and body, as a full-screen dialog. Same lean contract as iOS: the server quotes, normalizes the subject, applies threading headers, and resolves the sending identity (source message, or the From dropdown'salias_idfor a new message). The attach menu offers Photos (image/*) and Files (*/*— any type, no allowlist, nothing transcoded; the server re-detects types from bytes); picked files show as removable rows. An attachments send goes out asmultipart/form-datatomailbox/send(ApiClient.submitMultipart, fieldattachments[]), otherwise the plain JSON action. Client-side preflight mirrors the server's caps (10 files, 10 MB per file, 25 MB total) but the server is the authority; a rejected send keeps the draft and attachments in the sheet.
testTag values (mail_*) are the stable UI-test addressing:
mail_loading, mail_error, mail_retry, mail_list, mail_row,
mail_row_star, mail_empty, mail_search, mail_view_menu,
mail_new_message, mail_thread_back, mail_thread_subject,
mail_thread_loading, mail_thread_menu, mail_archive, mail_folders,
mail_folder_sheet, mail_folder_new, mail_folder_create, mail_reply,
mail_reply_all, mail_forward, mail_message_{id},
mail_attachment_{id}, and mail_compose_* (from, to, cc, subject,
body, attach, attachment_remove, send, cancel, error).Not in the module (the web reader remains for them): filter management and Gmail filter import, spam settings, admin oversight surfaces.
joinery-android-calendar (native calendar module)
joinery-android-calendar is the Android native personal-calendar surface
(android/joinery-android-calendar, depends on :joinery-android), the
platform counterpart to JoineryCalendarKit. An app adds the module and calls
JoineryCalendar.registerScreens() before mounting the root; the server's
calendar navigation destination then renders these screens.
The screens consume the core calendar actions (docs/calendar.md § API
surface) and mirror the iOS module's UX; the fixtures are shared across the
platforms' test suites but no client code is:
- Calendar screen (
CalendarScreen+CalendarStore) — a month grid (leading-blank cells honoring the device's first weekday, colored event dots per day, today ring, selected-day fill) over a selected-day agenda; chevrons and horizontal swipe change months, "Today" returns; each month fetches its window padded a week per side. Items group onto local days in the device timezone (CalDisplay.dayKeys), all-day items first. The agenda pull-refreshes. - Agenda rows — native entries open the entry editor; projected items
(events, bookings) show their type chip and push their web page in the
authenticated webview (
WebScreen) — the calendar never edits what it doesn't own. - Entry editor (
EntryEditorSheet) — title, date, all-day or timed (platform date/time pickers), availability blocking, and the full recurrence surface: frequency, interval stepper, weekly day chips (turning Repeats on pre-selects the entry date's weekday), monthly by-weekday (week-of-month + weekday), and series end (never / on date / after N). Editing fetchescalendar_entryand populates all of it; saves go tocalendar_entry_savewith the entry's stored IANA timezone (device zone for new entries). Recurring edits and deletes ask for scope (this / future / all) exactly like the web form — a "this occurrence only" save omits the recurrence payload so the settings stay on the series.
testTag values (cal_*) are the stable UI-test addressing:
cal_loading, cal_error, cal_retry, cal_grid, cal_month_title,
cal_prev_month, cal_next_month, cal_today, cal_add, cal_agenda,
cal_agenda_row, cal_agenda_empty, cal_selected_day, and
cal_entry_* (title, date, allday, start, end, blocks,
repeats, monthly_weekday, ends_date, save, cancel, delete,
error).joinery-android-ai-chat (native AI chat module)
joinery-android-ai-chat is the Android native AI-assistant surface
(android/joinery-android-ai-chat, depends on :joinery-android), the
platform counterpart to JoineryAIChatKit. An app adds the module and calls
JoineryAIChat.registerScreens() before mounting the root; the server's
ai_chat navigation destination then renders these screens.
The screens consume the joinery_ai/chat_* actions
(plugins/joinery_ai/docs/overview.md § Chat API surface) and mirror the iOS
module's UX; the fixtures are shared across the platforms' test suites but no
client code is:
- Conversation list (
ChatScreen+ChatListStore) — the caller's conversations pinned-first then newest, with server-side search, pull-to-refresh, a long-press menu for pin / rename / delete, and a compose button that opens a new chat. - Thread + composer (
ChatThreadView+ChatThreadStore) — the turns in a scroll (user bubbles right, assistant markdown left), a bottom composer, and per-turn / conversation token-cost labels. A running turn streams via the same send-then-poll transport the web reader uses:chat_sendreturns a poll handle, the store pollschat_poll(~600ms) folding partial text into the row until it settles; the poll'sactivity+running_secondsrender as a live status line on the running turn ("Waiting for glm-5p2… · 2m 40s"). A turn that proposes a mutating action shows an inline Confirm / Cancel card resolved throughchat_confirm. Long-press deletes a turn; a lightweight block-markdown renderer (headings, bullets, rules, inline bold/italic/code) rounds it out. - Settings sheet (
ChatSettingsSheet, gear top-bar button) — the per-chat controls: a model picker (catalog fromchat_controls, with a not-private warning), Data access / Web search toggles, a thinking-level picker, and the sampling fields (temperature, top-p, max tokens) plus custom instructions. Picker and toggle edits persist immediately viachat_set_capabilities; a new chat carries its control values as seed fields on the firstchat_send. A new chat defaults Data access on so the assistant is useful out of the box; the rest take the server's new-chat defaults. - Attachments (composer attach menu + turn rendering) — Photos
(
image/*) and Files (the server's non-image allowed set: PDF, text, CSV, JSON, Markdown, and images). Picked files show as removable chips and send asmultipart/form-datatochat_send(ApiClient.submitMultipart, fieldattachments[]); an attachments-only turn needs no message text. Images whose byte type isn't in the server's allowed set (notably HEIC) transcode to JPEG client-side. The server owns all validation and is the sole authority: a rejection surfaces its message, and a file dropped at commit for type drift comes back asattachment_warning, shown above the composer. Sent and historical attachments render on the user bubble — images as a thumbnail from the signedimage_urlthe serializer mints (5-minute TTL, so a cookieless native client loads it), everything else as a labeled file chip.
testTag values (chat_*) are the stable UI-test addressing:
chat_loading, chat_error, chat_retry, chat_list, chat_row,
chat_empty, chat_search, chat_new, chat_back, chat_thread_loading,
chat_thread_error, chat_thread_empty, chat_transcript,
chat_user_message, chat_assistant_message, chat_typing,
chat_activity, chat_confirm_card, chat_confirm_yes, chat_confirm_no, chat_composer,
chat_send, chat_attach, chat_attachment_strip,
chat_attachment_remove, chat_attachment_notice, chat_attachment_image,
chat_attachment_chip, chat_settings, chat_settings_loading,
chat_set_model, chat_set_data_access, chat_set_web_search,
chat_set_thinking, chat_set_instructions, chat_settings_done.Not in the module (the web chat remains for it): thread export.
joinery-android-member (native member surface module)
joinery-android-member is the Android native member-account surface
(android/joinery-android-member, namespace com.getjoinery.memberkit,
depends on :joinery-android), the platform counterpart to JoineryMemberKit.
An app adds the module and calls JoineryMember.registerScreens() before
mounting the root; the server's profile, orders, subscriptions,
events, conversations, and security navigation destinations then render
these screens, and builds without the module keep the web pages via each
entry's fallback URL. conversations and security are not menu entries —
they are reached from the dashboard and from Settings respectively, both
resolved through the same NativeScreenRegistry table so a core-only build
(no member module) falls back to the equivalent webview destination.
The screens consume the same seven purpose-built read actions and reused
mutations as iOS — profile_dashboard, order_list, subscription_summary,
my_events, conversation_list, conversation_thread, security_overview
for reads; orders_recurring_action, event_withdraw, security,
address_edit, phone_numbers_edit, conversation_send, and
conversation_action for mutations. The iOS JoineryMemberKit sources are the
behavioral contract (same payloads, same affordances, same deliberately-web
boundaries); models parse with the core JsonValue parser and stores follow
the established store conventions (MailboxStore is the reference): a
MemberPhase (loading / loaded / failed), a loadGeneration stale-load guard
so a slow older response never lands over a newer one, keep-last-good on
reload (a refresh never blanks the list), and both pull-to-refresh and an
ON_RESUME foreground refresh on every screen.
Each screen takes an optional onBack and shows a back arrow only when it is
non-null: NativeScreenContext.onExit is (() -> Unit)?, the navigation shell
passes the pop lambda for a pushed screen and null at a navigation root, and
JoineryMember.registerScreens() wires onBack = ctx.onExit into all six.
- Dashboard (
ProfileScreen+ProfileStore, registry nameprofile) — the user card, a needs-attention row (pending event surveys, unread message count), stat tiles, and recent-item lists. Every section renders strictly from keys theprofile_dashboardpayload actually sent — a settings-gated section (messaging_active/products_active/subscriptions_activeoff) is an absent key, not an empty placeholder. Tiles and rows navigate to the other native screens directly; Notifications and event survey links open throughctx.web. - Orders (
OrdersScreen+OrderListStore, registry nameorders) — a paginated, read-only order history (10/page) with each order's line-item summaries. - Subscriptions (
SubscriptionsScreen+SubscriptionStore, registry namesubscriptions) — active and cancelled subscriptions, current tier, and Cancel (a confirmation dialog intoorders_recurring_action, which cancels at period end unconditionally once called). Change Plan opens/profile/change-tierand Manage Billing opens/profile/billingthroughctx.web— deliberately web (Google Play IAP rules for digital subscriptions mirror Apple's; native purchase UI belongs tospecs/mobile_app_billing.md). The billing row shows only whenpayment_sourceisstripe. - Events (
EventsScreen+EventListStore, registry nameevents) — a status-tabbed (all / active / expired / canceled / completed), paginated (10/page) registration list. Rows open the session content page throughctx.web; Withdraw is a confirmation dialog intoevent_withdraw. - Conversations (
ConversationsScreen+ConversationListStorefor the inbox,ConversationThreadView+ConversationThreadStorefor one thread, registry nameconversations) — a paginated (20/page) inbox with swipe mute/unmute (right, immediate) and delete (left, which arms a confirmation dialog rather than deleting outright — deletion is destructive with no undo), opening into a bubble thread with cursor-paginated messages (before/afterISO cursors) and a compose bar. Opening a thread marks it read as a side effect of theconversation_threadcall, exactly as the web page does. There is no new-conversation entry point — the compose/member-picker is parked on a product decision on both platforms. - Security (
SecurityScreen+SecurityStore, registry namesecurity) — the app-session list (per-row Sign Out and Sign Out All Devices) and TOTP status with enable / confirm / disable / regenerate flows. The enable flow renders theprovisioning_uri(otpauth://…) as a QR natively with ZXing (com.google.zxing:core) — the server returns the URI, not an image. Enable confirms with a code undertotp_code; disable sends whichever code the user entered (authenticator or backup) as the server's singleconfirm_codefield, which classifies it by shape (6 digits = authenticator, 8 chars = backup). Revoking the session key that made the request signs the app out through the core 401 path:SecurityStore.revokereloadssecurity_overviewon the now dead key, the 401 firesApiClient.sessionInvalidatedHandler, and the app returns to the login screen. Passkeys and the Sealed Vault are web-managed — the WebView cannot expose platform WebAuthn — so a "Manage on the Website" row opens/profile/securitythrough the web-session bridge, and returning from it refreshes the overview.
joinery-android), not here — the
generic FormScreen renderer already covers them: SettingsScreen gained
Edit Address (address_edit) and Edit Phone Number (phone_numbers_edit)
form rows alongside Edit Account, and its Security row resolves the native
security screen through NativeScreenRegistry, falling back to the
/profile/security webview when no member module is registered.Stable testTag values use a member_* prefix for the module's screens
(e.g. member_profile_dashboard, member_profile_tile_events,
member_orders_list, member_subscriptions_change_plan,
member_events_status_menu, member_conversations_list,
member_conversation_send, member_conversation_delete_confirm,
member_security_list, member_security_revoke_all,
member_security_revoke_all_confirm, member_security_manage_web,
member_security_totp_qr). The core Settings rows keep the core settings_*
prefix: settings_address_edit, settings_phone_numbers_edit,
settings_security.
joinery-android-dnsfilter (native DNS-filtering module)
joinery-android-dnsfilter is the Android native DNS-filtering surface
(android/joinery-android-dnsfilter, namespace com.getjoinery.dnsfilter,
depends on :joinery-android), the platform counterpart to JoineryDNSFilterKit.
It is brand-neutral — any ScrollDaddy-style deployment reuses it with only
branding changed. An app adds the module and calls
JoineryDnsFilter.registerScreens(config) before mounting the root, passing a
DnsFilterConfig (deployment origin, brand name, strict-mode availability); the
server's dns_protection and dns_devices navigation destinations then render
these screens, and builds without the module keep the /profile/dns_filtering/…
web pages via each entry's fallback URL. The ScrollDaddy app
(android/scrolldaddy-android, application id app.scrolldaddy.android,
client_app scrolldaddy-android, login-only) is the reference consumer.
The screens consume the same dns_filtering/ action surface as iOS — devices,
account_summary, catalog, scheduled_block_edit, device_edit,
block_filter_set, block_rule_add, block_rule_delete — with models parsed
by the core JsonValue parser and stores following the established conventions
(a DnsPhase and a loadGeneration stale-load guard). The iOS
JoineryDNSFilterKit sources are the behavioral contract.
- Protection (
ProtectionScreen+ProtectionStore, registry namedns_protection) — this phone's home: registers the handset as a device (device_edit, pinned locally bydevice_id), one-tap enable through a single in-app VPN consent dialog, and Turn Off. "Protected" reflects the liveVpnServicestatus. - Devices (
DevicesScreen+DeviceListStore, registry namedns_devices) — every device on the account, each opening its always-on editor. - Always-On editor (
AlwaysOnEditorScreen+BlockEditorStore) — category filters (general are the free floor; advanced are tier-gated), service toggles, and custom domain rules. Every toggle is save-on-change throughblock_filter_set; "Allow" submits as removing the row (the resolver-merge "Allow = no row" invariant lives server-side). Tier gates render locked; the server re-enforces every one.
VpnService (DnsFilterVpnService). Standard
mode claims only DNS: it advertises a virtual resolver address, routes just that
address into the tun, and forwards every captured query (IPv4 and IPv6) to the
device's doh_url over DNS-over-HTTPS — the resolver applies this device's
server-side policy. The IP/UDP framing (DnsPacket) and the DoH client
(DohClient) are pure and unit-tested; a foreground-service notification runs
while filtering, START_STICKY plus a boot receiver restore it after a
restart, and stopping the service or uninstalling reverts the device's DNS
automatically. The strict-mode datapath (all-traffic routing + connection-level
SNI/IP dropping via the unit-tested HardBlockList / TlsClientHello engine) is
the deferred Phase 4 work: the engine is present and tested but not wired into
the live service, and DnsFilterConfig.strictModeAvailable gates the Strict
control off until it ships.Stable testTag values use screen-scoped names (protection_list,
protection_status_label, protection_register, protection_enable,
protection_edit_rules, protection_turn_off, devices_list, editor_list,
filter_{key}, service_{key}, rule_add_button).
joinery-android-billing (native billing module)
joinery-android-billing is the Android native in-app purchase surface
(android/joinery-android-billing, namespace com.getjoinery.billing,
depends on :joinery-android and the Play Billing library), the platform
counterpart to JoineryBillingKit. The app calls
JoineryBilling.registerScreens() at launch; the server's billing
navigation destination then renders natively, with the web pricing page as
the version-skew fallback.
- BillingScreen (registers
billing) — same surface as the iOS kit: server catalog + summary, Play-localized prices (ProductDetails), the Play Billing purchase sheet (PlayBillingConnector), restore, and manage-routing by source (Google Play deep link).
store/play_claim with the app's package name; the
server fetches authoritative state from the Play Developer API, grants the
tier, and acknowledges the purchase — the module never acknowledges locally.
Purchases carry the catalog's app_account_token as obfuscatedAccountId
for webhook-side user linkage.Stable testTag values: billing_list, billing_current_tier,
billing_plan_{productId}, billing_restore, billing_manage_play_store,
billing_other_source. Parsing tests share the iOS fixture envelopes
(src/test/resources/fixtures).
Standing up a new branded app
- Pick a
client_appidentifier (e.g.joinery-member-ios); the app sends it withclient-versionon every API request (hyphen header form). - Add a tab list for it to the
app_navigationsetting (or rely ondefault). - Optionally set its minimum version in
api_min_client_versionsonce shipped. - iOS: create
ios/<app-name>in this repo with an XcodeGenproject.ymldepending on the local../joinery-kitpackage, and a single app source file that builds aJoineryConfigand mountsJoineryAppRoot(config:keychainService:)— seeios/joinery-member-iosfor the reference shape. - Android: add an application module under
android/<app-name>depending on:joinery-android(plus any content modules, e.g.:joinery-android-mail), with aMainActivitythat calls each content module'sregisterScreens(), builds aJoineryConfig, and callsJoineryAppRoot(config, storeFileName)insetContent— seeandroid/joinery-member-androidfor the reference shape.
Tests
tests/functional/api/app_platform_test.php— server platform: navigation filtering and tab pinning, bridge minting and target validation, single-use and expiry, app display mode, and lifetime coupling (key revocation and password change). Runs against dev with curl; see the harness header for usage.- JoineryKit + JoineryMailKit + JoineryCalendarKit unit tests — on the mini
(after syncing
ios/to the build area): `cd dev/joinery-ios/joinery-kit && xcodebuild test -scheme JoineryKit-Package -destination "platform=iOS Simulator,name=iPhone 16"` (JSON parser, form definition and navigation parsing, visibility engine, submission bodies, error mapping, mail payload parsing, and calendar payload parsing + month math — all against captured live fixtures). tests/functional/ios/phase2_gate.sh— the native-core gate: drives the JoineryMember XCUITest suites in the Simulator against dev, orchestrating server state per suite (probe field for the no-rebuild form-change proof, reset-code extraction fromiem_inbound_email_messageswith outbound mail flipped to localhost SMTP for that leg so the locally-hosted fixture inbox receives in seconds, 426 viaapi_min_client_versions, failed-auth rate limit last — it leaves the runner's IP inside the 15-minute failed-auth window). The fixture account ([email protected], a store-mode alias on the live inbound domain) has its credentials in~/.joinery_app_test_credson the dev box; the reset suite rotates the password every run.tests/functional/ios/phase3_gate.sh— the navigation + webview gate: Phase 2 auth/form suites as regression, tab bar + More from the navigation endpoint, an entry create/delete round-trip on the native calendar screens (the soft-deleted row verified incal_entries), orders and conversations in the webview, mailbox read + reply on the native mail screens (fixtures viaphase3_fixtures.php: mailbox grant for the fixture user plus thephase3.senderstore alias the reply lands on — arrival verified iniem_inbound_email_messageswith outbound flipped to localhost SMTP for the leg), a plugin profileMenu entry appearing with no rebuild (menu_probe.phpsyncs a probe entry throughPluginManager::syncMenus, prune removes it), an off-site probe link on/profileopening Safari, and — last, because it revokes every session key for the fixture user — Revoke All on the App Sessions page signing out the webview and native layers in one gesture (app_bridge_key_check_seconds=0for that leg).- joinery-android unit tests — on the mini (after syncing
android/to the build area~/dev/joinery-android): `~/gradle-8.9/bin/gradle :joinery-android-member:testDebugUnitTest` (member payload parsing over the shared fixtures) plus the core, mail, calendar, and ai-chat module test tasks. The member module's fixtures (src/test/resources/fixtures/*.json) are the verbatim JoineryMemberKit fixtures, parity by construction. tests/functional/android/member_gate.sh— the Android instrumented gate: drives thejoinery-member-androidCompose test suites on the emulator (joinery_testAVD) against dev, mirroringphase3_gate.shleg for leg and reusing its platform-neutral seed scripts unchanged (phase3_fixtures.php,phase3_conversation_fixtures.php). Legs: the native member screens render with no webview present (dashboard, orders, subscriptions, events, conversations, security); a conversation round-trip (reply verified inmsg_messages); mailbox read + reply and the folder picker filing a dedicated seeded thread (verified inilm_inbound_label_members); the deliberately-web surfaces loading through the bridge from their native entry points (change-tier from subscriptions, notifications from the dashboard); Revoke All from the native security screen signing the app out (app_bridge_key_check_seconds=0, last); and a module-less build landing every flipped entry on its web fallback. The fixture password never appears in a command line: the script streams the creds file over stdin to/data/local/tmp/joinery_member_gate.credson the emulator (deleted at gate end) and the tests read it there. Do not run the emulator while the mini's Ollama is serving a generation — they starve each other.