The rest of the phone¶
New in 1.13.0.
An app does not end at its own screens. It makes the phone tick under a finger, reads a sentence aloud, opens a page, leaves a file where the person can find it, puts a dentist in the calendar. Each of these is one call:
from apkpy_lib import *
home = Screen(id="home")
status = label("", id="status", screen=home)
def call_us():
status.set_value(phone.dial("+351 912 345 678"))
button("Call us", id="call", screen=home, command=call_us)
run(start_screen=home)
There is nothing to declare and no permission to ask for. The dialler opens
with the number typed, the person presses the green button, and status
says ok. In the Previewer the same tap raises a sheet that shows the
dialler arriving with that number, so you can see what the app handed over
without a phone.
Three kinds of answer¶
Every call on this page answers in one of three ways, and it is always clear which from what the call does.
A word, at once. A call that hands over to another app, or does something that takes no time, answers before the next line runs:
Later, in on_result. A call that needs the person, or takes time,
answers when it is over. The function takes exactly two things: whether it
worked, and a value — the words that were heard, the name the file was
saved under — or, when it did not work, the word for why not:
def heard(ok, words):
if ok:
city.set_value(words)
else:
status.set_value(words) # "cancelled", "nothing_heard", ...
def listen():
speech.listen(prompt="Say the name of a city", on_result=heard)
Yes or no. A question goes straight into an if:
The words are the same on the phone and in the Previewer. They are written once, in a table the build and the Previewer both read, so an app tested at the desk never meets a word on the phone that it has not seen.
Haptics¶
haptics.tap() # pressing something
haptics.tick() # lighter: a value changing under the finger
haptics.heavy() # the bump of a long press
haptics.success() # it worked
haptics.error() # it did not
haptics.vibrate(300) # the motor, for 300 ms
Two different things share the motor, and the person has a switch for each.
The first five are touch feedback: Android's own effects, the ones the keyboard and the system use, tuned to the phone's motor. They need no permission. They are for answering a finger — a press, a value changing, a form that would not send.
haptics.vibrate(ms) is the app's own buzz: a timer that ended, a hit
in a game. It runs the motor for a length of time, from 1 to 5000
milliseconds, and it is the only call here that adds the VIBRATE
permission to the app, and only when the app uses it.
Both respect the person. Touch feedback follows the phone's touch-feedback
setting; vibrate(ms) follows the phone's vibration switch (on Android 13
and newer it has a setting of its own, the one for media and games). With
the setting off, the phone stays still and the call answers off. That is
the right outcome, not a failure to work around — and it is why haptics
is the wrong tool for something the person must not miss. A notification
vibrates by the rules the person set for notifications.
haptics.available() says whether there is a motor at all.
| Answer | |
|---|---|
ok |
The phone was asked to do it. |
off |
The owner has that kind of vibration switched off. |
no_vibrator |
This device has no vibration motor. |
In the Previewer: the status strip shows the vibration glyph for as long as the phone would shake.
Speech¶
Reading aloud¶
speech.say("Your order is ready.")
speech.say("O seu pedido está pronto.", language="pt-PT", rate=0.9)
language is a tag like en-GB or pt-PT; left out, the phone's own
voice is used. rate goes from 0.25 to 4.0 and pitch from 0.5 to 2.0,
with 1.0 as normal. A new sentence replaces the one being read.
speech.stop() stops, and speech.is_speaking() says whether something is
being read right now.
on_result= is called when the sentence has been said to the end:
| Answer | |
|---|---|
ok |
It was said to the end. |
stopped |
Something else was said, or stop() was called, first. |
no_engine |
No text-to-speech engine is installed. |
language_unavailable |
The engine has no voice for that language. |
failed |
The engine gave up on this text. |
In the Previewer: the sentence appears as a caption over the app for as
long as it would take to say, and on_result is called when it goes.
Dictation¶
def heard(ok, words):
if ok:
note.set_value(words)
speech.listen(prompt="What should I write down?", on_result=heard)
The phone's own microphone screen opens, the person speaks, and words is
what was recognised. The app does not ask for the microphone: the screen
belongs to the phone's speech service, which has its own permission.
speech.can_listen() says whether this phone can do it, which is worth
asking before showing a microphone button.
| Answer | |
|---|---|
| the words | ok is true. |
cancelled |
The person closed the microphone without speaking. |
nothing_heard |
It opened and no words were recognised. |
no_recogniser |
No app on this phone turns speech into text. |
In the Previewer: a sheet rises with a field; type what would have been
said and press Done, or close it to answer cancelled.
A web page¶
There are two calls, and the difference is who is in charge of the page.
browser.open() shows the page in a tab of the person's browser, drawn over
the app with the app's colour on its bar. Closing it comes back to the app.
The person is signed in to what they are signed in to, their password
manager works, and the app knows nothing of what they do there. This is the
one to use for a link.
browser.show("https://example.com/terms", title="Terms")
browser.show(html="<h1>Receipt</h1><p>Two tickets, 68.00</p>", title="Receipt")
browser.show() shows the page inside the app, over the whole screen, with
a bar of its own, a progress line and a close button. It takes an address
or HTML the app wrote. Back goes to the page before, and then closes.
HTML the app wrote does not have to be a whole page. A fragment —
<h1>Terms</h1><p>...</p> — is drawn in the app's own colours, with its
link colour and readable margins, so it looks like a screen of the app
rather than a web page from 1996. A <style> of your own in the markup
wins over those defaults, and markup that is already a whole page (it has
an <html> tag) is shown exactly as written.
on_result(ok, address) is called when the person closes it, with the last
address that was shown. Links that are not web pages — mailto:, tel: —
are handed to the phone.
| Answer | |
|---|---|
ok |
open(): the page was opened. |
invalid |
Not an http:// or https:// address, and no html=. |
no_browser |
No app on this phone opens web pages. |
In the Previewer: both open the page in the desktop's browser.
What browser.show() is not: a web view among the app's other components.
It covers the screen. An app that is mostly a web page is better written as
a web page.
Text of the app's own¶
def save():
word = files.write("journal.txt", page.get_value())
if word == "ok":
files.append("history.txt", datetime.now() + "\n")
def opened():
page.set_value(files.read("journal.txt"))
lifecycle(home, on_mount=opened)
files.write(name, text) keeps the text as a file in the app's own folder,
in place of what was there. files.append(name, text) adds to the end, and
makes the file if there is none. files.read(name) answers the text — or
"" when there is no such file, so a first start needs no special case.
The folder is the app's: nothing else on the phone reads it, it needs no
permission, and it goes when the app is uninstalled. To hand a file to the
person, where they can find it, there is files.save_as() below.
Names. A name is notes.txt, or has folders in it — drafts/monday.txt
— which are made as needed. The whole path that files.path() and
files.download() answer means the same file as the bare name. A name
cannot leave the folder (../x), be empty, or hold a character a file name
cannot have on a computer (< > : " | ? * \): the Previewer keeps these
files on your computer, and a name it could not write is refused on the
phone too, so what works at the desk works built.
write() / append() answer |
|
|---|---|
ok |
The file was written. |
invalid |
That is not the name of a file in the app's folder. |
failed |
It could not be written: no room is left, or a folder has that name. |
What is kept. UTF-8, exactly as given: no line ending is changed on the way in or on the way out. A file is replaced whole or not at all — an app closed in the middle of a save still has the file it had.
Limits. These read and write on the spot, so they are for text the size
of a document, not of a database: for rows there is db, for settings
storage. There is no list of the files yet. Inside a background job the
build stops (J7004); files.download() and files.delete() are the two
that work there.
In the Previewer: the same files, in ~/.apkpy/files/.
Saving a file¶
def saved(ok, name):
if ok:
toast("Saved as " + name)
def export():
files.save_as("expenses.csv", rows_as_csv(), mime="text/csv", on_result=saved)
The phone's own "Save to" screen opens with the name filled in. The person
chooses the folder — Downloads, Documents, their Drive — and may change the
name; name in on_result is the one the file ended up with. Because the
person chose the place, there is no storage permission involved, on any
version of Android.
files.save_copy(path, name="") does the same for a file the app already
has: a photo it took, a recording, a database it exported.
| Answer | |
|---|---|
| the name | ok is true. |
cancelled |
The person closed the screen without choosing. |
not_found |
save_copy(): there is no such file in the app. |
no_app |
This phone has no file picker. |
failed |
The place was chosen and the file could not be written there. |
In the Previewer: the computer's own Save dialog.
Downloads¶
def landed(ok, name):
if ok:
toast(name + " is in Downloads")
downloads.start("https://example.com/report.pdf", title="October report", on_result=landed)
The phone downloads the file itself, with its own notification and progress,
into the Downloads folder. The download carries on if the app is closed.
name= is the file's name; left out, it is taken from the address.
title= is what the notification says. On Android 9 and older the file
lands in the app's own downloads folder instead of the shared one.
This is for a file the person keeps. For data the app reads — JSON, an image
to show — https.get() is the call.
| Answer | |
|---|---|
| the name | ok is true: the file is in Downloads. |
invalid |
Not an http:// or https:// address. |
no_space |
The phone ran out of room. |
failed |
The server refused or the connection did not last. |
unavailable |
The phone's download service is switched off. |
In the Previewer: the file is really downloaded, into
~/.apkpy/downloads, and appears in the Previewer's notification shade.
The calendar¶
calendar.add_event("Dentist", "2026-11-03 15:30", location="Rua Augusta 12")
calendar.add_event("Holiday", "2026-08-01", end="2026-08-14")
The calendar app opens on a new event with everything filled in, and the person saves it — into whichever of their calendars they choose. The app asks for no permission and never sees the person's calendar.
Times are text: "2026-11-03 15:30", in the phone's own time zone. A date
with no time is a whole day. With no end= an event lasts an hour, or the
one day; all_day=True makes it whole days whatever was written.
| Answer | |
|---|---|
ok |
The calendar opened with the event filled in. |
invalid |
start= or end= is not a date, or the end is before the start. |
no_app |
No calendar app is installed. |
In the Previewer: a sheet shows the event as the calendar would receive it: title, day and time, place, notes.
Not covered: reading the person's events, or adding one without them seeing it. Both need a permission Google Play asks an app to justify.
The dialler and messages¶
dial() opens the dialler with the number typed. sms() opens the messages
app on a new message to that number, already written. In both the person
presses the last button. That is deliberate: placing a call or sending a
message without the person is a permission Google Play gives only to apps
whose purpose is calling or messaging.
Spaces, brackets and dashes in the number are fine.
| Answer | |
|---|---|
ok |
The other app opened. |
invalid |
There is no number in what was given. |
no_app |
This device has no dialler or no messages app — a tablet, often. |
In the Previewer: a sheet shows the number, and for a message the text in its bubble.
The microphone¶
def started(ok, reason):
if not ok:
status.set_value(reason)
def record():
if recorder.is_recording():
storage.set("memo", recorder.stop())
else:
recorder.start(on_result=started)
def play():
audio.play(storage.get("memo", ""))
recorder.start() asks for the microphone the first time, in Android's own
dialog, and starts recording. recorder.stop() answers the path of the
file — an .m4a in the app's own storage, which audio.play() plays and
files.save_copy() hands to the person. recorder.seconds() is how long it
has been recording.
This is the only call on this page that asks the person for a permission,
and it adds RECORD_AUDIO to the app only when the app uses it.
| Answer | |
|---|---|
ok |
It is recording. |
busy |
It was recording already. |
permission_denied |
The person did not allow the microphone. |
permission_blocked |
Refused for good; only the phone's settings change that. |
no_microphone |
This device has no microphone. |
failed |
Another app may be holding the microphone. |
In the Previewer: the permission is asked with Android's three answers,
the computer's microphone records into a .wav, and the green microphone
dot lights in the status strip while it does. With no microphone on the
computer, the file is silence of the right length.
Printing¶
printing.html("<h1>Invoice 42</h1><p>Two tickets to Lisbon: 68.00</p>", name="Invoice 42")
printing.text(notes.get_value(), name="Notes")
Android's print screen opens with the document: any printer the phone
knows, and "Save as PDF", which is how an app makes a PDF. name is the
document's name, and the PDF's file name. html() takes a document written
in HTML — headings, paragraphs, tables; text() prints plain text as it
is, line breaks included.
A fragment is printed as ink on paper with a margin and a plain typeface.
As with browser.show(), a <style> of your own wins, and a whole
<html> page is printed exactly as written.
| Answer | |
|---|---|
ok |
The print screen opened with the document. |
unavailable |
This device cannot print. |
failed |
The document could not be laid out. |
In the Previewer: the document opens in the desktop's browser, where Ctrl+P prints it.
What arrives from outside¶
Two lines at the top of the file say what may open the app:
incoming.links("notes", preview="notes://open/42")
incoming.shares("text", preview="Shared from another app")
They are declarations, not calls: they change what is built. With the first,
a link like notes://open/42 — in a message, on a web page, in a QR code —
opens the app. With the second, the app appears in the share sheet of every
other app when text is being shared. incoming.shares() takes "text",
"image" or "file".
The app opens at its first screen, and asks what brought it:
def arrived():
link = incoming.link()
if link != "":
open_note(link)
incoming.clear()
lifecycle(home, on_mount=arrived)
incoming.link() is the address, incoming.text() the shared text, and
incoming.file() the path of a copy of the shared image or file, inside the
app. Each is "" when nothing of the kind arrived. incoming.clear()
forgets it, so that coming back to the screen does not handle it twice.
preview= is what the Previewer pretends arrived, so the path can be
written and seen at the desk.
https links¶
This offers the app for addresses on that site. Android will ask the person
which app to use until the site says the app is its own, with a file at
https://notes.example.com/.well-known/assetlinks.json that names the app
and the key it is signed with. That file is the site's to publish; ApkPy
writes the app's side.
A scheme of your own (notes) needs none of that, and is the one to start
with. A scheme that already belongs to the phone (tel, mailto, geo)
is refused at build time.
When a call is wrong¶
A mistake stops the build, with the line and what to write instead:
APKPY U2048 - A call to the phone that ApkPy cannot build
Where:
writehere.py:5
def heard(words):
What happened:
heard() is handed two things by speech.listen(): ok and value
Expected:
def heard(ok, value):
That covers a call that does not exist (haptics.buzz(), which is also
said in the Previewer when the call is reached), an argument a call does
not take, an on_result that is not a function of two parameters,
incoming.link() in an app with no incoming.links(), and a scheme the
phone already owns.
Three more things worth knowing:
- Not in a background job. These calls need a screen and a person. Inside
background_jobthey are refused the way every screen call is (J7004).downloads.start()is itself the background way to fetch a file. - Your own names win. If the app has
phone = inputs("Phone"), thenphoneis the app's field, andphone.get_value()is what it always was. Call it something else to dial. - Only what is used is built. An app that calls
haptics.tap()gets one small class; an app that calls none of this is built exactly as before.
What is still missing¶
Said plainly, because it is what you need before you plan an app around it: a home-screen widget, a quick settings tile, ads, updates through Play, and the health sensors are not here. Neither is reading the calendar, or a web view placed among other components. What ApkPy reaches has the whole list, and your own Java is the way to reach an Android class ApkPy has no call for.
The complete app with every call on this page is
37_the_rest_of_the_phone.py.