Skip to content

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:

word = phone.dial("912 345 678")    # "ok", "invalid" or "no_app"

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:

if recorder.is_recording():
    recorder.stop()

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:

def said(ok, reason):
    next_step()


speech.say(instructions, on_result=said)
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("https://example.com/help")

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.

files.save_copy(recorder.stop(), name="interview.m4a", on_result=saved)
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

phone.dial("+351 912 345 678")
phone.sms("912 345 678", "On my way")

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.

incoming.links("https", host="notes.example.com")

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_job they 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"), then phone is the app's field, and phone.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.