dobrovolskiy.com
RU
← Notes2026-08-30 · by Dmitriy Dobrovolskiy

RYTA: one web bundle for Android, PWA and a messenger mini-app, updated over the air without store releases

Case study of RYTA 3.0, a freight and machinery marketplace: a Kotlin WebView shell with a SHA-256-verified OTA bundle engine, a FastAPI backend, phone identity by inbound call, T-Bank payments and a MAX messenger bot mirroring the whole UI.

RYTA 3.0 is a marketplace where carriers and shippers publish cargo, trucks and machinery as pins on a self-hosted map. One web bundle runs inside a thin Kotlin WebView shell on Android, as a PWA at web.ryta.online and as a bot plus mini-app inside the MAX messenger. The Android app updates itself over the air from a FastAPI backend with hash-verified atomic bundle swaps — more than 300 bundles shipped without a single store release.

The constraint that shaped everything

RYTA had a legacy .NET version with real users and listings. The new version had to ship fast, change daily during the launch weeks, and reach people on Android, on the web and inside the MAX messenger — without waiting for app-store review every time a button moved. That rules out a classic native app. It also rules out Capacitor-style tooling with a hosted update service, because the update path had to be ours.

One bundle, three shells

The application is a vanilla JavaScript PWA with a MapLibre GL map on PMTiles from our own geo stack. The same bundle is:

The app code follows a core template model: application code never edits core files; core improvements are pulled in by a manifest-driven script, so the shell and the update engine are shared with other apps built on the same template.

The OTA engine

On start the shell checks the backend for a newer bundle on its channel, downloads it, verifies the SHA-256 hash, and swaps it in atomically with rollback if the new bundle fails to boot. Channels allow staged rollouts; a minimum-native-version gate keeps a bundle from running on a shell that lacks a needed capability. Over 300 bundles have been published this way.

Identity without passwords

A user proves a phone number by calling an 8-800 number. The Rostelecom virtual PBX sends an HMAC-signed webhook to the backend, which matches the caller to the pending verification request. No SMS codes, no passwords; one person can have many devices. Subscriptions are keyed by the verified phone.

Backend and money

FastAPI with SQLAlchemy on PostgreSQL, MinIO for photos, FCM push built natively and scoped to direct messages to avoid fan-out storms, topic chat rooms plus DMs. Paid services go through T-Bank acquiring (Init plus a signed notification webhook) against an admin-managed price catalogue. A SvelteKit admin panel covers users, listings, billing, push, client errors, themes, taxonomies and a custom icon picker that converts Android vector XML to SVG.

A client-side diagnostics journal exports errors straight into the admin; release scripts detect environment drift; a redroid container provides a virtual Android device for testing; a script generates the Play Store screenshots, captions and feature graphic.

Numbers

About 36 600 lines in 116 files across backend, PWA, Android shell, admin and the MAX bot; users and listings migrated from the .NET system; running on a self-managed Docker/Caddy host with a private registry.

What I would do differently

Build the diagnostics journal before the first beta, not after the first bug report from a phone I could not see. And decide the channel policy (who gets which bundle) up front; retrofitting it onto users already in the wild is awkward.

FAQ

Is a WebView app acceptable to Google Play?

Yes, as long as it is a real application and not a wrapper around a third-party site. RYTA ships its bundle inside the APK and works offline-first; over-the-air bundle updates for the web layer are a widely used pattern.

Why verify a phone by an inbound call instead of an SMS code?

SMS delivery in the region is unreliable and costs per message; a call to an 8-800 number is free for the user, cannot be intercepted the same way, and the PBX webhook gives the backend the caller ID directly.

Can the same approach work for my app?

If your product is mostly UI over an API and needs to change often, a shell plus OTA bundles is usually the fastest route. I can set up the shell, the update engine and the backend live in hourly sessions.

Want something like this?

I build it live on a Zoom call, you watch the screen, the timer stops when you say stop. First 15 minutes are free.

$50 / hourBook an hour

More projects

RYTA: freight & machinery marketplaceOffline-first, over-the-air-updated freight/transport load board for Android, web and the MAX messenger