Skip to content

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

home_widget.define("Steps today", "0", caption="steps", icon="directions_walk")
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:

if home_widget.count() == "0":
    add_widget.show()

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

quick_tile.define("Walk", icon="directions_walk")

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:

def offered(ok, word):
    status.set_value(word)


quick_tile.offer(on_result=offered)
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.

ads.setup("test")

"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

def arrived():
    ads.banner("test")


lifecycle(home, on_mount=arrived)

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:

ads.prepare(interstitial="test", rewarded="test")

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.

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:

adb logcat -s ApkPy

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:

updates.start("immediate", on_result=ready)
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 answers unavailable — 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.