packages

Notifications

Ask permission, schedule a notification for now or later (after N seconds, at a date, daily, weekly, monthly, yearly or by calendar fields), decide how to show one while the app is open, react to taps, manage Android channels, and receive push through APNs on iOS and Firebase Cloud Messaging on Android. iOS uses UserNotifications; Android posts with NotificationManager and schedules with AlarmManager. snake_case and the Expo camelCase names both work.

  • pyxpo-notifications
  • v0.1.0a2
  • Native: Swift + Kotlin
  • Like expo-notifications
A notification scheduled five seconds ahead after the permission prompt, on Android and iOS.
A notification scheduled five seconds ahead after the permission prompt, on Android and iOS.

Install

Install the package into the same environment as pyxpo.

$pip install pyxpo-notifications

Add notifications to modules in your app config, then import it in your code.

pyproject.toml
[tool.pyxpo]
modules = ["notifications"]
python
import pyxpo_notifications as Notifications

Needs Python 3.9+ and pyxpo>=0.1.0a2.

This module has native code. After you add it, run pyxpo run or pyxpo build again so it is compiled into the app. Hot reload alone is not enough.

Platforms

iOSiOS 15+. Everything; calendar triggers and the badge count are iOS only.
AndroidAPI 24+. Everything; Android 13+ shows a permission prompt.
Desktop previewNot supported: calls raise NotSupportedError after argument checks.
WebNot supported.

Quick start

app/index.py
from pyxpo import Button, Text, View, component, use_async, use_state
import pyxpo_notifications as Notifications

Notifications.set_notification_handler({
    "handle_notification": lambda n: {"should_show_banner": True, "should_show_list": True,
                                      "should_play_sound": False, "should_set_badge": False},
})


@component
def Reminder():
    taps, set_taps = use_state(0)
    status, set_status = use_state("Not scheduled")

    async def run():
        if taps == 0:
            return
        perm = await Notifications.request_permissions_async()
        if not perm["granted"]:
            set_status("Permission denied")
            return
        nid = await Notifications.schedule_notification_async({
            "content": {"title": "Tea is ready", "body": "Your 2nd cup", "data": {"cup": 2}},
            "trigger": {"type": Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, "seconds": 5},
        })
        set_status("Scheduled %s for 5 seconds from now" % nid[:8])

    use_async(run, [taps])

    return View(Text(status), Button("Notify me in 5 s", lambda: set_taps(taps + 1)))

Configuration

Exact alarms on Android

Local notifications need no config. Android alarms are inexact and may be batched; for exact timing on Android 12+, add the permission yourself.

pyproject.toml
[tool.pyxpo.android]
permissions = ["android.permission.SCHEDULE_EXACT_ALARM"]

Remote push plugin

For push, add the config plugin. google-services.json from the Firebase console holds no secrets, but keep the Firebase service account key that sends pushes out of the app and the repo.

pyproject.toml
[tool.pyxpo]
modules = ["notifications"]
plugins = [["pyxpo_notifications.plugin", { googleServicesFile = "./google-services.json" }]]
OptionTypeDefaultWhat it does
googleServicesFilestrnoneThe google-services.json from the Firebase console. Without it Android has no FCM token.
modestr"development"iOS aps-environment: "development" or "production".
enableBackgroundRemoteNotificationsboolfalseiOS: adds remote-notification to the background modes.

Permissions

pyxpo adds these to Info.plist and AndroidManifest.xml for you when the module is listed.

iOSNone in Info.plist. request_permissions_async() shows the system prompt.
AndroidPOST_NOTIFICATIONS and RECEIVE_BOOT_COMPLETED are added for you. SCHEDULE_EXACT_ALARM is opt-in.

API reference

Permission

await get_permissions_async()→ dict

The current permission, without asking.

await request_permissions_async(permissions=None)→ dict

Asks if needed. {"status", "granted", "canAskAgain"} plus ios or android details. ios options: allow_alert, allow_badge, allow_sound (default True), allow_provisional, allow_critical_alerts, provide_app_notification_settings, allow_display_in_car_play.

use_permissions(get=True, request=False)→ (response, request, get)

The permission as a hook.

Schedule and manage

await schedule_notification_async(request)→ str

request is {"content", "trigger", "identifier"?}. Returns the identifier. Give your own identifier to replace a reminder. trigger None shows it now.

await get_next_trigger_date_async(trigger)→ int or None

Milliseconds since 1970.

await get_all_scheduled_notifications_async()→ list[dict]

[{"identifier", "content", "trigger"}]

await cancel_scheduled_notification_async(id), cancel_all_scheduled_notifications_async()→ None

Cancel one or all schedules.

await get_presented_notifications_async()→ list[dict]

Notifications still shown by the system.

await dismiss_notification_async(id), dismiss_all_notifications_async()→ None

Remove shown notifications.

await get_badge_count_async(), set_badge_count_async(n)→ int / bool

iOS. On Android get returns 0 and set returns False.

Handler and taps

set_notification_handler(handler)→ None

{"handle_notification", "handle_success", "handle_error"}. handle_notification may be async, has 3 seconds and returns should_show_banner, should_show_list, should_play_sound, should_set_badge. Without a handler nothing shows while the app is open. None removes it.

add_notification_received_listener(fn)→ Subscription

Each notification that arrives while the app is open.

add_notification_response_received_listener(fn)→ Subscription

Taps. A plain tap has actionIdentifier == DEFAULT_ACTION_IDENTIFIER. Subscription.remove() is safe to call twice.

await get_last_notification_response_async(), clear_last_notification_response_async()→ dict or None

The last tap, including the one that launched the app.

use_last_notification_response()→ dict or None

None until a tap, then the response.

Android channels

Android lets an app change only the name and description of an existing channel. On iOS these return None, [] or do nothing.

await set_notification_channel_async(id, channel)→ dict

Keys: name, importance, description, sound ("default" or None), vibration_pattern, enable_vibrate, enable_lights, light_color, lockscreen_visibility, show_badge, bypass_dnd, group_id.

await get_notification_channels_async(), get_notification_channel_async(id), delete_notification_channel_async(id)

List, read or delete channels.

Remote push

await get_device_push_token_async()→ {"type", "data"}

type is "ios" (APNs) or "android" (FCM). Send data to your server.

add_push_token_listener(fn)→ Subscription

Called when the token changes.

await unregister_for_notifications_async()→ None

Stop receiving push.

await get_expo_push_token_async()

Raises NotSupportedError: pyxpo does not use Expo's push service.

Constants

SchedulableTriggerInputTypes, AndroidImportance, AndroidNotificationVisibility, AndroidNotificationPriority, IosAuthorizationStatus, PermissionStatus

Enums.

DEFAULT_ACTION_IDENTIFIER

The actionIdentifier of a plain tap.

Types

Requests are plain Python dicts. snake_case and camelCase keys both work; an unknown key raises ValueError.

content

KeyTypeDescription
title, subtitle, bodystrBoth platforms.
datadictJSON-compatible; returned to you on tap.
badgeintiOS. From 0.
soundbool or strTrue, "default", or a file name.
sticky, auto_dismissboolAndroid.
priorityAndroidNotificationPriorityAndroid. MIN ... MAX.
colorstrAndroid. "#RRGGBB" or "#RRGGBBAA".
interruption_levelstriOS. "passive", "active", "timeSensitive" or "critical".

trigger

KeyTypeDescription
TIME_INTERVALseconds, repeatsseconds > 0. iOS repeats only every 60 seconds or more.
DATEdateA datetime or milliseconds, in the future. A bare datetime or number works as the trigger.
DAILYhour, minuteEvery day.
WEEKLYweekday, hour, minuteweekday 1 is Sunday.
MONTHLYday, hour, minuteEvery month.
YEARLYmonth, day, hour, minutemonth 0 is January.
CALENDARyear, month, day, weekday, hour, minute, second, repeats, timezoneiOS only. month 1 is January.
channel_idstrAdd to any trigger to post on that Android channel.

Examples

Triggers

A daily alarm, a date ten minutes from now, and the next fire date of a trigger.

Permission granted and one notification scheduled, on Android and iOS.
Permission granted and one notification scheduled, on Android and iOS.
python
from datetime import datetime, timedelta

import pyxpo_notifications as Notifications

T = Notifications.SchedulableTriggerInputTypes


async def schedule():
    await Notifications.schedule_notification_async({"content": {"title": "Wake up"},
                                                     "trigger": {"type": T.DAILY, "hour": 7, "minute": 30}})
    await Notifications.schedule_notification_async({"content": {"title": "Soon"},
                                                     "trigger": datetime.now() + timedelta(minutes=10)})
    return await Notifications.get_next_trigger_date_async({"type": T.TIME_INTERVAL, "seconds": 60})

Taps

Read data from the tap, including the tap that launched the app.

python
import pyxpo_notifications as Notifications

sub = Notifications.add_notification_response_received_listener(
    lambda r: print(r["notification"]["request"]["content"]["data"], r["actionIdentifier"]))


async def on_start():
    last = await Notifications.get_last_notification_response_async()
    if last:
        print("opened from", last["notification"]["request"]["identifier"])
        await Notifications.clear_last_notification_response_async()

Android channel

Create the channel once, then post on it with channel_id in the trigger.

python
import pyxpo_notifications as Notifications


async def setup():
    await Notifications.set_notification_channel_async("reminders", {
        "name": "Reminders",
        "importance": Notifications.AndroidImportance.HIGH,
        "vibration_pattern": [0, 250, 250, 250],
        "light_color": "#FF8800",
        "lockscreen_visibility": Notifications.AndroidNotificationVisibility.PUBLIC,
    })
    await Notifications.schedule_notification_async({
        "content": {"title": "Hi"}, "trigger": {"type": "timeInterval", "seconds": 5, "channel_id": "reminders"}})

Push token

Send the token to your server and push with APNs or FCM. A received push goes to the same listeners as a local notification, with trigger {"type": "push", ...}.

python
import pyxpo_notifications as Notifications


async def register():
    await Notifications.request_permissions_async()
    token = await Notifications.get_device_push_token_async()   # {"type": "ios" | "android", "data": "..."}
    Notifications.add_push_token_listener(lambda t: print("new token", t))
    return token

Recipe: daily reminder

A toggle that schedules or cancels one daily reminder with a fixed identifier.

python
from pyxpo import Button, Text, View, component, use_async, use_state
import pyxpo_notifications as Notifications

T = Notifications.SchedulableTriggerInputTypes
REMINDER_ID = "daily-water"


@component
def Water():
    taps, set_taps = use_state(0)
    on, set_on = use_state(False)

    async def run():
        if taps == 0:
            scheduled = await Notifications.get_all_scheduled_notifications_async()
            set_on(any(s["identifier"] == REMINDER_ID for s in scheduled))
            return
        if on:
            await Notifications.cancel_scheduled_notification_async(REMINDER_ID)
            set_on(False)
        else:
            perm = await Notifications.request_permissions_async()
            if perm["granted"]:
                await Notifications.schedule_notification_async({
                    "identifier": REMINDER_ID,
                    "content": {"title": "Drink water", "body": "A glass now"},
                    "trigger": {"type": T.DAILY, "hour": 10, "minute": 0},
                })
                set_on(True)

    use_async(run, [taps])
    return View(Text("Reminder is on" if on else "Reminder is off"),
                Button("Turn off" if on else "Turn on at 10:00", lambda: set_taps(taps + 1)))

Good to know

  • A button handler of lambda: coroutine() is never awaited. Count taps in state and let use_async do the work, as in the quick start.
  • Ask for permission at a moment the user understands, not at launch, and handle granted == False and canAskAgain == False.
  • Firebase is needed only for remote push on Android. iOS push needs a real device or a signed simulator build.
  • Categories, action buttons, attachments and custom sounds are not supported yet.
  • On the desktop window, guard calls with from pyxpo.module_api import platform.