Outside the app¶
New in 1.13.0.
Some of what an app is does not sit on any of its screens. A widget lives on the home screen and a tile in quick settings; an ad comes from a network; a newer version waits on Google Play; the steps of the day are in the phone's health records. This page is those five.
from apkpy_lib import *
home_widget.define("Steps today", "0", caption="steps", icon="directions_walk")
home = Screen(id="home")
def walked():
home_widget.set("6 482", caption="steps")
button("I walked", id="walked", screen=home, command=walked)
run(start_screen=home)
Build that and the app has a widget. The person adds it from the launcher
like any other, it shows 0 steps, and after the button it shows 6 482.
One line at the top¶
A widget is not something an app does while it runs. It is something the app has — Android reads it out of the installed package, before the app has ever been opened. So four of the five families begin with one line, written once, at the top of the file, that says what the app is built with:
home_widget.define("Steps today", "0", caption="steps", icon="directions_walk")
quick_tile.define("Walk", icon="directions_walk")
ads.setup("test")
health.policy("https://example.com/privacy")
These lines are read when the app is built. Nothing happens when they are reached, which is why they take text written out and not a variable. A call of a family whose line is missing stops the build and says which line to add:
home_widget.set(): this app has no widget
Add this at the top of the file:
home_widget.define("Steps", "0", caption="today", icon="directions_walk")
updates needs no line: every app on Play has a newer version sooner or
later.
Only what is used is built in. An app with no widget has no widget code, and
only an app that calls ads carries Google's ad library.
A widget on the home screen¶
title |
The small line on top: what the widget is about. |
text |
The large line. What it shows before the app has said anything. |
caption |
A quieter line beside or under the text. Optional. |
icon |
A Material icon in a round badge. Optional. |
name |
What the launcher's list of widgets calls it. The app's name when left out. |
From any screen:
home_widget.set("6 482", caption="steps") # what it shows from now on
home_widget.set("38 min", title="Active today") # a new title as well
set() replaces the text and the caption — leave the caption out and the
widget has none. The title stays as it is unless one is given.
The widget shows what it was last told. The app does not have to be running:
the phone can be restarted and the widget still says 6 482. A tap on it
opens the app.
It has two drawings. One cell high, it is a row: the badge, the title, and the text with its caption beside it. Dragged taller, it becomes a card with the text large. The launcher lets the person resize it and the widget picks the drawing that fits.
Its colours are the app's. They come from the app's Theme: surface,
text, secondary text and the accent for the badge. The home screen follows
the phone and not the app, so the widget is light when the phone is light
and dark when the phone is dark, whatever appearance the app itself is
in.
home_widget.count() # "0", "1", ... how many are on the home screen
home_widget.pin() # ask the launcher to add one
pin() has the launcher ask the person where to put the widget; it answers
ok when the question was put, and unsupported on a launcher that does
not take the offer. Some phones — Xiaomi's among them — first ask whether
the app may add things to the home screen at all. count() is how an app
knows whether to offer:
In the Previewer: each set() shows the widget itself for a moment at the
top of the window, as the launcher draws it. pin() raises the launcher's
question with the card drawing, and count() becomes 1 once it is
accepted.
What it is not. One widget for an app, with this one design. No list, no
buttons, no picture. And it changes when a screen of the app calls set():
a background job cannot reach it.
A tile in quick settings¶
The tile appears in the list the person sees when they edit quick settings, with the name and the icon given here.
quick_tile.set(True, subtitle="On a walk") # lit, with a line under the name
quick_tile.set(False) # out
quick_tile.is_active() # True or False
Quick settings draws the tile, in the phone's own colours: lit in its accent when active, grey when not. The subtitle shows on Android 10 and newer.
A tap on the tile opens the app at its first screen, and that screen asks how the person arrived:
def arrived():
if quick_tile.opened():
start_walk()
quick_tile.clear()
lifecycle(home, on_mount=arrived)
opened() stays true until clear(), so going back to the first screen
does not start a second walk. It is the same shape as
a link that opened the app.
From Android 13 an app can ask to have its tile added, in a dialog of Android's:
| Answer | |
|---|---|
added |
The tile is in quick settings now. |
already |
It was there already. |
dismissed |
The person said no. |
unsupported |
Before Android 13. The person adds it by editing quick settings. |
The dialog is not yours. Android draws it, over the app, and an app
cannot restyle it: its colours are the phone's — dark when the phone is,
tinted by the wallpaper on Android 12 and newer — its words are in the
phone's language, and its layout is whatever that phone's maker left it.
Next to an app with a look of its own it can seem out of place, and on some
phones the buttons are stacked and their labels cut short. What the app
gives it is the tile's name and icon. If that moment matters to you, say in
your own words what the tile is for before you call offer().
In the Previewer: set() shows the tile for a moment at the top of the
window, lit or not. offer() raises Android's question. Nobody pulls a
shade down on a desk, so quick_tile.define("Walk", preview=True) makes
the Previewer behave as if the tile had opened the app; the phone ignores
preview=.
What it is not. A switch that runs your Python without opening the app. A tap always opens the app; what the app does next is up to it.
Ads¶
Google AdMob, with three kinds of ad.
"test" is Google's own sample: a real ad, marked Test Ad, that costs
nobody anything and can be tapped freely. It works on the phone exactly as
it does in the Previewer, so an app can be finished and tested before there
is an AdMob account at all. Before release, the app id and each ad unit are
replaced with the ones AdMob gives you:
ads.setup("ca-app-pub-1234567890123456~1234567890")
ads.banner("ca-app-pub-1234567890123456/1234567890")
Never tap your own real ads
AdMob closes accounts for it. While the app is in development, keep
"test".
A banner¶
A banner belongs to the screen that asked for it and stays there. It runs
along the bottom — position="top" puts it under the clock instead — and
the screen is made that much shorter, so nothing of the app is under it.
The room is taken at once, at the height the ad will have, so the screen
does not jump under a finger when the ad arrives. If no ad comes, the
screen gets its room back. While the keyboard is up, the banner waits.
On a screen with a bar of tabs along the bottom, the banner sits under the
bar. ads.hide_banner() takes it away.
Between two screens¶
def carry_on(ok, word):
go_to(next_level)
def level_done():
ads.interstitial("test", on_result=carry_on)
An interstitial takes the whole screen at a pause in the app — a level
finished, an article read — until the person closes it. carry_on is
called either way: with closed after an ad, or with the reason there was
none. The app carries on in both cases; an ad that did not come is not an
error to show the person.
For a reward¶
def paid(ok, amount):
if ok:
coins.set_value(str(int(coins.get_value()) + int(amount)))
def watch():
ads.rewarded("test", on_result=paid)
A rewarded ad is one the person chooses to watch, behind a button that says
what they get. ok is true only when they watched to the reward, and
amount is the number set for that ad unit in AdMob (Google's sample gives
10). Close it early and the answer is skipped.
Fetching ahead¶
An ad takes a moment to arrive. Fetched ahead, it opens the instant it is asked for:
Call it when the screen opens. Without it, the first interstitial is
fetched when asked for and shown only if it arrives within a few seconds
and the person is still where they were — an ad that takes the screen ten
seconds after the pause it was meant for is worse than none. It is kept
for the next call instead, and this one answers not_ready. After an ad
has been shown, the next one is fetched by itself.
| Answer | |
|---|---|
closed |
An interstitial was shown and closed. |
| the amount | A rewarded ad was watched to the reward; ok is true. |
skipped |
A rewarded ad was closed before the reward. |
not_ready |
The ad took too long. It is kept for the next call. |
no_fill |
There was no ad to show this time. |
network |
There is no connection. |
invalid |
The ad unit is not one AdMob knows. |
failed |
The ad could not be loaded or shown, or consent is not settled. |
Consent¶
In the European Economic Area, the United Kingdom and Switzerland the person has to be asked before ads use their data. ApkPy does this for you: before the first ad of any kind, Google's own consent form is shown where the law requires it, and no ad is requested until it is answered. Outside those places nothing is shown. An app does not have to remember any of it.
With "test" the form is Google's sample one — it says Publisher Test
Ads — so the whole sequence can be seen on a phone before the app has an
AdMob account. For your own app the form has to exist: create a GDPR
message for it under Privacy & messaging in AdMob. Without one there
is nothing to show, and Google will not serve ads to those countries.
Two calls are there for the app that wants a say in it:
ads.consent(on_result=ready) # ask now, rather than at the first ad
if ads.needs_privacy_options(): # the law wants a way back to the form
privacy_row.show()
ads.privacy_options(on_result=done) # open it again, from your settings
In the Previewer: a banner is a strip marked Ad where the banner goes,
and the screen is shorter by its height. An interstitial covers the window
until it is closed; a rewarded one counts down first, and closing it early
answers skipped. The consent form is shown only when ads.consent() or
ads.privacy_options() is called.
No ad on your own phone? An ad that does not come says nothing on the screen — it is not the person's problem — and the reason goes to the log:
The usual one on a developer's phone is the phone itself: a private DNS or a VPN that filters ads blocks Google's ad hosts, test ads included, and the library reports only an internal error. Pause it and the test ad arrives.
What it costs. Google's ad library adds several megabytes to the app,
and only an app that calls ads carries it. On Play, say that the app
contains ads and fill in what the library collects under Data safety.
Updates from Google Play¶
Play knows whether the copy of the app on this phone is the newest. An app can ask, and have the new version downloaded without the person leaving it.
def found(ok, word):
if word == "available":
update_card.show()
elif word == "downloaded":
restart_card.show()
updates.check(on_result=found)
There are two ways to update, and the difference is whether the person can carry on meanwhile.
Flexible downloads behind the app. Play asks first, in a sheet of its own; when the download is done the app is told, and decides when to restart:
def ready(ok, word):
if word == "downloaded":
restart_card.show()
def update():
updates.start("flexible", on_result=ready)
def restart():
updates.install() # the app closes and opens in the new version
Immediate takes the whole screen until the new version is installed, for an update the app cannot run without:
| Answer | |
|---|---|
available |
Play has a newer version than the one running. |
downloaded |
The new version is on the phone, waiting for install(). |
ok |
The app is restarting into the new version. |
none |
This is the newest version. |
cancelled |
The person said not now. |
not_allowed |
Play does not offer this kind of update for this version. |
not_from_play |
This copy of the app did not come from Google Play. |
nothing |
install() was called with no update downloaded. |
failed |
Play could not be asked, or the download did not finish. |
Only a copy that came from Play can be updated from Play. An app built
on your computer and installed over a cable answers not_from_play to
every call, which is correct and is all there is to see until the app is on
Play. To try the whole thing for real, upload a build to internal testing
(or share one with internal app sharing), install it from there, then
upload one with a higher version_code.
In the Previewer: there is always an update waiting, so the screens an
app builds around one can be seen. Flexible raises Play's sheet and then
downloads for a moment; immediate covers the window with Play's screen.
After install() there is no update any more, as on the phone.
Health data¶
The phone keeps one store of health records — Health Connect — that a watch, a scale and the fitness apps write into. An app may read from it with the person's leave, one kind of record at a time.
health.policy("https://example.com/privacy")
home = Screen(id="home")
steps = label("–", id="steps", screen=home)
def got(ok, value):
if ok:
steps.set_value(value)
def asked(ok, word):
if ok:
health.read("steps", on_result=got)
def connect():
health.ask("steps, heart_rate", on_result=asked)
button("Connect", id="connect", screen=home, command=connect)
health.policy() is where the app's privacy policy lives. Health Connect
shows a link to it on the screen where the person decides, and refuses to
ask at all for an app that has none.
health.ask() opens Health Connect's own screen, with a switch for each
kind named. It answers ok when all of them were allowed and denied
otherwise; health.allowed("steps") says, for one kind, whether the app
may read it now.
A reading¶
A reading is one number for a stretch of time. Health Connect adds it up itself, so a walk counted by both the phone and a watch is counted once.
health.read("steps", on_result=got) # today so far
health.read("steps", start="2026-10-01", end="2026-10-07", on_result=got)
health.read("sleep", start="2026-10-04 20:00", on_result=got) # since then
With no times, the reading is today since midnight. start and end are
written like 2026-10-04 or 2026-10-04 20:00. A start with no hour is
that day's midnight; an end with no hour is the whole of that day; no end
is now.
| Kind | The number is |
|---|---|
steps |
steps |
distance |
metres |
active_calories |
kilocalories burned by moving |
calories |
kilocalories burned in all |
heart_rate |
beats a minute, on average |
resting_heart_rate |
beats a minute, on average |
sleep |
minutes asleep |
weight |
kilograms, on average, to one decimal |
floors |
floors climbed |
exercise |
minutes of exercise |
hydration |
litres, to two decimals |
The value arrives as text, like every value from the phone: int(value)
for steps, float(value) for weight.
A total of nothing is 0. An average of nothing is no number at all, so
heart_rate, resting_heart_rate and weight answer no_data for a
stretch of time in which nothing was recorded.
| Answer | |
|---|---|
| the number | ok is true. |
no_permission |
The app has not been allowed to read this kind. |
no_data |
Nothing was recorded in that time (averages only). |
invalid |
The time cannot be read, or the end is not after the start. |
unavailable |
This phone has no Health Connect. |
denied |
From ask(): the person did not allow all of it. |
failed |
Health Connect did not answer. |
The kind is written out. Each kind is a permission in the app's
manifest, and the build puts in only the ones the app names — so
health.read("steps", ...) and not health.read(kind, ...). An app that
reads three kinds has three small functions.
In the Previewer: a desk has no body, so the answer is a plausible
reading. The same day always gives the same number and no two days quite
the same, which is what a week's chart needs to look like a week.
health.ask() raises Health Connect's question with the kinds listed.
What it is not, and what Play asks.
- It reads; it does not write records.
- It works on Android 14 and newer, where Health Connect is part of the
system. Before that
health.available()is false and every call answersunavailable— ask before showing a health screen. - Health Connect lets an app read only while it is open, and a fresh permission reaches thirty days back.
- An app that asks for health permissions has to declare it in the Play Console and say what each kind is for. Play reviews it.
All five together¶
38_outside_the_app.py
is one screen with a row for every call on this page, and a card that shows
what each one answered.