Previewer versus Android¶
One Python declaration drives two renderers. The Previewer optimizes the edit loop; the generated project uses Android widgets and services.
Hot Previewer
Fast layout, callbacks, simulated device events and deterministic test data.
- Tk-based visual renderer
- desktop file and network workers
- explicit simulation for push and GPS
- no Android permission or OEM behavior
Native Android
Material widgets, Activities, RecyclerView, Media3, FCM and device services.
- Java, XML and Gradle output
- native lifecycle and permissions
- real codecs, GPS and notifications
- Android Studio inspection
Behavior map¶
| Feature | Previewer | Android |
|---|---|---|
| Components | desktop widgets styled from the same tokens | Material/native views and generated drawables |
| Navigation | screen-tree switch | Activity navigation and extras |
| Feeds | pooled virtual rows | RecyclerView with targeted notifications |
| SQLite | Python SQLite | SQLiteDatabase |
| Encrypted values | local preview key store | Android-backed encrypted storage |
| HTTP/uploads | background desktop workers | Android background network workers |
| WebSocket | WSS client with reconnect | Android WSS client with lifecycle callbacks |
| Audio/video | desktop media backend | foreground MediaSession and Media3 |
| Push | push.simulate() |
Firebase Cloud Messaging |
| Extended notifications — 1.7.0 release | themed in-device drawer, fixed header, scrollable cards and real Python action callbacks | native system shade, channels, permissions and PendingIntents |
| Location | explicit preview_route |
fused device location |
| Sensors | explicit buttons, sliders and near/far simulation | SensorManager with hardware availability and Activity lifecycle |
| Battery | desktop status/simulator snapshots | Android battery and power-saving state |
| Static wallpaper — 1.7.0 release | image confirmation; simulated, never changes the desktop |
image confirmation, then WallpaperManager applies to home/lock/both |
| Routes | compatible HTTP routing endpoint | same request contract from Android |
| Background jobs | on-disk queue in ~/.apkpy/jobs |
WorkManager OneTimeWorkRequest queue |
| Soft keyboard | none: the desktop keyboard is always there | the window resizes and the layout moves up |
| Leaving a screen | the app bar's back arrow, or Alt+Left | the arrow, the Back gesture or the hardware key |
For sensor callback shapes, units and what each simulator control does, see Sensors and battery. Accelerometer, gyroscope and pressure are documented as 1.7.0 additions. Only device testing confirms hardware support; the desktop pressure simulator works even when the phone being tested has no barometer.
For the notification drawer's controls, light/dark captures, action callbacks and testing checklist, see Notifications. The new desktop design does not change Android's system UI. Preview cards do not prove phone permission, sound, vibration, lock-screen behavior or remote FCM delivery.
Leaving a screen¶
On Android every Screen is an Activity, so there are always two ways out: an
app bar's back arrow, and the system Back gesture. action("arrow_back") with
no command= of its own compiles to finish().
The Previewer swaps one widget tree for another. It keeps a history of the screens behind the current one and offers the same two ways out:
- the app bar's back arrow, on the same
arrow_backicon name the generator tests for; - Alt+Left, standing in for the Back gesture a desktop window does not have.
Both follow Android's order: an open drawer() closes first, and only then
does Back leave the screen. At the first screen, Back does nothing -- on a
phone that is where it would close the app, and the Previewer stays put rather
than shutting its own window.
A screen already open behind you is brought forward rather than stacked a
second time, which is what FLAG_ACTIVITY_REORDER_TO_FRONT does for the tabs
of a bottom_nav.
model_screen = Screen(id="model_screen")
app_bar("Default model",
leading=action("arrow_back", label="Back"),
screen=model_screen)
Escape is not bound: overlays bind it themselves, and a modal that both closed and navigated would be worse than no shortcut.
The soft keyboard¶
The Previewer has no soft keyboard. A desktop window is never covered by one, so a screen that looks right there tells you nothing about what happens on a phone when a field is focused. This is the one gap in the map above that cannot be closed by making the Previewer better -- there is nothing to simulate that would be true.
What Android does, measured on a Pixel 9 Pro (API 36) across the three shapes a generated screen takes:
| Screen | Root the generator writes | Keyboard opens |
|---|---|---|
No app bar, scroll=False |
LinearLayout |
window resizes; content stays visible |
With an app_bar() |
RelativeLayout wrapping a NestedScrollView |
window resizes; the bar stays put |
Screen(scroll=True) |
NestedScrollView |
window resizes; the focused field scrolls into view |
ApkPy does not declare windowSoftInputMode in the generated manifest.
Android's default, adjustUnspecified, resolved to a resize in all three
cases, and adding the attribute would change the manifest of every app to no
observable effect.
Two things this does not promise:
- A list does not follow the keyboard. A
virtual_collectionkeeps its scroll position when the window shrinks, so the newest row can end up above the fold. Callscroll_to_end()after the field gains focus if the screen is a conversation. - It was measured on one API level. On API 24-29 a screen with no
scrolling view in it can resolve to
adjustPaninstead, which slides the whole window up rather than resizing it. If your app supports those levels and puts a field near the bottom of a non-scrolling screen, check it there.
Visual parity is a contract, not pixel identity¶
Text metrics, system fonts and native controls can differ slightly. The goal is the same hierarchy, spacing, color, behavior and responsive decisions. Native Android conventions take precedence for permission dialogs, media controllers, date pickers and notifications.
When a mismatch is a library bug, fix both the Previewer renderer and the
generator. Editing only generated Java is temporary because a later
apkpy build regenerates it.
The strip along the top¶
The Previewer draws a clock and the signal, wifi and battery marks so a screen is composed against the same 24dp the phone takes. It is scenery -- there is no carrier, no network and no battery behind it -- and the phone draws its own.
The marks are vectors on the icon catalogue's 24x24 grid, rasterised by the
same antialiasing pass every icon= goes through, and they take the app bar's
colours. They used to be block characters borrowed from the system font, which
at that size read as a row of dashes.
A number with no name is a number each side guesses¶
The mismatches that survive longest are not the loud ones. They are the values both renderers had to pick, that nobody ever wrote down, so each side picked something reasonable in its own file and neither looked wrong on its own.
Two were found and repaired in 1.5.0, and they have the same shape:
| What | Previewer | Android |
|---|---|---|
label() with no stylesheet |
14px | 16sp |
| the bottom bar's active label | bold, 10pt (13.3px) | normal, 12sp |
Neither was a rendering bug. Both were two literals, written into two files at
different times, with no shared name to disagree about. label() is the most
used component in the library, so that one was in every app ever built.
The repair in both cases was the same, and it is the pattern worth copying:
give the value a name in a neutral module both sides import, then let each
renderer read it. apkpy_lib/theme.py holds the type ramp,
apkpy_lib/motion.py the durations, apkpy_lib/icons.py the glyphs. A test
that asserts both sides resolve the same value from the same table is what
keeps them from drifting again -- a test comparing two hard-coded numbers only
proves that somebody typed the same thing twice.
When you find one of these, check the platform before choosing a side. The
bottom-bar label was settled by reading Material's own
Widget.MaterialComponents.BottomNavigationView, which points the active and
inactive text appearances at the same 12sp caption -- so the phone was right
and the desktop was inventing a third state signal.
Release test¶
- Exercise every primary action in the Previewer.
- Build a fresh project rather than reusing stale generated files.
- Compare a narrow phone and a wide/tablet layout.
- Test loading, empty, error and offline states.
- Put the app in the background during media, upload, GPS and WebSocket work.
- Test a physical device before store release.