diff --git a/.github/workflows/build-notifications-plugin.yml b/.github/workflows/build-notifications-plugin.yml
new file mode 100644
index 00000000..ecb9ca1f
--- /dev/null
+++ b/.github/workflows/build-notifications-plugin.yml
@@ -0,0 +1,152 @@
+name: Build Notifications Plugin
+
+# Builds plugins/Notifications (sokol_notifications, local scheduled notifications) on every change to the plugin,
+# and commits the prebuilt Android and iOS libraries back to plugins/Notifications/libs/ on main — every shipped
+# library is a CI build, never a local one. Android and iOS are the only native backends; the managed layer (a stub
+# elsewhere) is compiled for every platform symbol set. A change is not done until every job is green.
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - 'plugins/Notifications/**'
+ - '!plugins/Notifications/libs/**'
+ - '.github/workflows/build-notifications-plugin.yml'
+ pull_request:
+ branches: [ main ]
+ paths:
+ - 'plugins/Notifications/**'
+ - '!plugins/Notifications/libs/**'
+ - '.github/workflows/build-notifications-plugin.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ build-android:
+ name: Build Android (arm64-v8a, armeabi-v7a, x86_64)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+ with:
+ packages: ''
+ - name: Install Android NDK
+ run: |
+ sdkmanager --install "ndk;27.2.12479018"
+ echo "ANDROID_NDK=$ANDROID_SDK_ROOT/ndk/27.2.12479018" >> $GITHUB_ENV
+ - name: Build
+ run: ./plugins/Notifications/scripts/build-android.sh
+ - name: Check 16 KB page alignment (Google Play, Android 15+ — 64-bit ABIs only, as the NDK flag)
+ run: |
+ READELF="$ANDROID_NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf"
+ for so in plugins/Notifications/libs/android/{arm64-v8a,x86_64}/release/libsokol_notifications.so; do
+ align=$("$READELF" -l "$so" | awk '/LOAD/ {print $NF; exit}')
+ echo "$so: LOAD align $align"
+ [ "$align" = "0x4000" ] || { echo "::error::$so is not 16 KB aligned ($align)"; exit 1; }
+ done
+ - uses: actions/upload-artifact@v4
+ with:
+ name: notifications-android
+ path: plugins/Notifications/libs/android/**/*
+ retention-days: 30
+
+ build-ios:
+ name: Build iOS (${{ matrix.target }})
+ runs-on: macos-latest
+ strategy:
+ matrix:
+ target: [device, simulator-arm64, simulator-x64]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup CMake
+ uses: lukka/get-cmake@latest
+ - name: Build
+ run: ./plugins/Notifications/scripts/build-ios.sh ${{ matrix.target }}
+ - name: Determine upload path
+ id: upload_path
+ run: |
+ case "${{ matrix.target }}" in
+ device) echo "path=plugins/Notifications/libs/ios/arm64" >> $GITHUB_OUTPUT ;;
+ simulator-arm64) echo "path=plugins/Notifications/libs/ios/simulator-arm64" >> $GITHUB_OUTPUT ;;
+ simulator-x64) echo "path=plugins/Notifications/libs/ios/simulator-x64" >> $GITHUB_OUTPUT ;;
+ esac
+ - uses: actions/upload-artifact@v4
+ with:
+ name: notifications-ios-${{ matrix.target }}
+ path: ${{ steps.upload_path.outputs.path }}/**/*
+ retention-days: 30
+
+ build-managed:
+ name: Managed layer (every platform symbol set)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+ - name: Compile the managed layer for every platform symbol set
+ run: |
+ set -e
+ mkdir -p /tmp/notifications-managed && cd /tmp/notifications-managed
+ cat > notifications.csproj <<'PROJ'
+
+
+ net10.0
+ enable
+ true
+ false
+ $(DefineConstants);$(NotificationsSymbols)
+
+
+
+
+
+ PROJ
+ for SYMS in "" "__LINUX__" "__WINDOWS__" "__MACOS__" "__ANDROID__" "__IOS__" "WEB"; do
+ echo "=== DefineConstants: '${SYMS:-}'"
+ dotnet build -c Release -p:NotificationsSymbols="$SYMS" --nologo -v q
+ done
+
+ commit-libs:
+ name: Commit the Android and iOS libraries
+ needs: [build-android, build-ios, build-managed]
+ runs-on: ubuntu-latest
+ if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
+ steps:
+ - name: Download the artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+ - name: Commit the libraries
+ run: |
+ git clone https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git repo
+ cd repo
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ mkdir -p plugins/Notifications/libs/android plugins/Notifications/libs/ios/arm64 \
+ plugins/Notifications/libs/ios/simulator-arm64 plugins/Notifications/libs/ios/simulator-x64
+ cp -r ../artifacts/notifications-android/* plugins/Notifications/libs/android/
+ cp -r ../artifacts/notifications-ios-device/* plugins/Notifications/libs/ios/arm64/
+ cp -r ../artifacts/notifications-ios-simulator-arm64/* plugins/Notifications/libs/ios/simulator-arm64/
+ cp -r ../artifacts/notifications-ios-simulator-x64/* plugins/Notifications/libs/ios/simulator-x64/
+ LIBS="plugins/Notifications/libs"
+ if [ -z "$(git status --porcelain $LIBS)" ]; then
+ echo "No sokol_notifications library change to commit"
+ exit 0
+ fi
+ git add $LIBS
+ git commit -m "Auto-update built sokol_notifications libraries (Android, iOS) [skip ci]" \
+ -m "Generated from commit: ${{ github.sha }}" \
+ -m "Workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ git pull --rebase origin main
+ git push origin main
+ echo "✓ Committed sokol_notifications libraries"
diff --git a/docs/ANDROID_PROPERTIES.md b/docs/ANDROID_PROPERTIES.md
index 2d56cf80..530f8213 100644
--- a/docs/ANDROID_PROPERTIES.md
+++ b/docs/ANDROID_PROPERTIES.md
@@ -95,6 +95,19 @@ Each request is bounded by the permission's own API window:
> app sees a plain denial. Any UI that waits for the user's answer then waits for an answer that can
> never arrive.
+#### Asking later instead of at launch (`AndroidPermissionsNoAutoRequest`)
+
+A permission the app requests itself, at a moment that makes sense to the user, is listed here too.
+It stays in the manifest but is left out of the launch-time prompt:
+
+```xml
+android.permission.BLUETOOTH_SCAN;android.permission.POST_NOTIFICATIONS
+android.permission.POST_NOTIFICATIONS
+```
+
+Typical use: notifications (`plugins/Notifications` asks for `POST_NOTIFICATIONS` when the app calls
+`Notifications.RequestPermission()`).
+
### Bluetooth / BLE permissions
Android 12 (API 31) replaced the Bluetooth permission model outright, so an app supporting anything
diff --git a/plugins/Notifications/README.md b/plugins/Notifications/README.md
new file mode 100644
index 00000000..fe69cbe4
--- /dev/null
+++ b/plugins/Notifications/README.md
@@ -0,0 +1,71 @@
+# Notifications plugin
+
+Local (on-device) scheduled notifications: the app schedules its own text for a time, and the OS shows it even when
+the app isn't running. No push service, no server, no SDK account — nothing leaves the device.
+
+| Platform | Backend |
+|----------|---------|
+| Android 8+ (API 26) | WorkManager one-time work posts the notification (survives reboots and app updates; time approximate) |
+| iOS 15+ | `UNUserNotificationCenter` with a calendar trigger (fires on time, survives reboots) |
+| Desktop, Web | none — `Notifications.Available == false`, permission reads `Denied`, calls complete as no-ops |
+
+## API
+
+```csharp
+Notifications.Init(); // once at start
+Notifications.Configure("Reminders", "icons/notify.png"); // Android: channel name + small icon (asset path)
+Notifications.OnEvent += e => { /* Permission / Pending / PendingDone */ };
+// every frame:
+Notifications.Poll();
+
+if (Notifications.Permission == NotificationPermission.NotDetermined)
+ Notifications.RequestPermission(); // → Permission event (Granted / Denied)
+
+Notifications.Schedule(id: 1, at: DateTimeOffset.Now.AddDays(2), expires: null, "My App", "Time for a game?");
+Notifications.Cancel(1);
+Notifications.CancelAll();
+
+int tapped = Notifications.TakeOpened(); // id the user tapped to open the app, once (0 = none)
+Notifications.OpenSettings(); // after a denial
+```
+
+- **Ids** are chosen by the app, 1..63 (iOS keeps at most 64 pending requests per app). Scheduling a pending id
+ replaces it. `Cancel` / `CancelAll` also remove a notification already shown.
+- **Text** comes from the app, already localized — the plugin holds none.
+- **Android timing:** Doze and battery savers can run the work late. Pass `expires` to drop a notification that
+ would arrive too late (e.g. at night) instead of showing it.
+- **The app in front:** iOS shows nothing while the app is in the foreground; Android shows it.
+- **`TakeOpened`:** check it at start and on every return to the foreground (`SAPP_EVENTTYPE_RESUMED`). Android
+ delivers a tap on a running app through `onNewIntent`, which needs the `singleTask` `SokolNativeActivity`
+ (`AndroidFullscreen`, the default).
+
+## Permission
+
+- **Android 13+** needs the `POST_NOTIFICATIONS` runtime permission. Declare it, and keep it out of the generic
+ launch-time prompt so the app can ask at a moment that makes sense:
+ ```xml
+ …;android.permission.POST_NOTIFICATIONS
+ android.permission.POST_NOTIFICATIONS
+ ```
+ Android shows no dialog at all after two refusals; `RequestPermission` then answers `Denied` and the app should
+ offer `OpenSettings()`.
+- **Android 8–12:** no prompt; notifications are on unless the user switched them off.
+- **iOS:** `requestAuthorization(.alert, .sound)`. No entitlement, no APNs, no `UIBackgroundModes`.
+
+## Opt-in (Directory.Build.props)
+
+```xml
+../../plugins/Notifications/libs/android
+../../plugins/Notifications/platform/android/java
+../../plugins/Notifications/libs/ios/arm64/release
+```
+```xml
+
+```
+`androidx.work:work-runtime` comes in through `platform/android/gradle-deps.txt` automatically.
+
+## Building
+
+The shipped libraries are built by CI (`.github/workflows/build-notifications-plugin.yml`). Local builds, for
+development only: `scripts/build-android.sh` (needs `ANDROID_NDK` and the prebuilt `libsokol.so`) and
+`scripts/build-ios.sh [device|simulator-arm64|simulator-x64|all]`.
diff --git a/plugins/Notifications/managed/Notifications.cs b/plugins/Notifications/managed/Notifications.cs
new file mode 100644
index 00000000..e2c14639
--- /dev/null
+++ b/plugins/Notifications/managed/Notifications.cs
@@ -0,0 +1,167 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+
+///
+/// Local (on-device) scheduled notifications for Sokol.NET apps — WorkManager on Android,
+/// UNUserNotificationCenter on iOS. No push and no server: the app schedules its own, already
+/// localized text and the OS shows it. Asynchronous answers arrive as
+/// s raised by , which the app calls
+/// once per frame from its game loop.
+///
+/// Ids are app-chosen, 1..; scheduling a pending id replaces it.
+/// On platforms without a backend (desktop, web) is false,
+/// the permission reads as denied and every call completes through the same event path.
+///
+public enum NotificationPermission
+{
+ /// Never asked: may show the OS prompt.
+ NotDetermined = 0,
+ Granted = 1,
+ /// Refused, or switched off in the system settings ().
+ Denied = 2,
+}
+
+public enum NotificationEventType
+{
+ /// The answer to : Value is a .
+ Permission = 1,
+ /// One pending notification: Value = id, Time = fire time (unix seconds, UTC).
+ Pending = 2,
+ /// End of the list started by .
+ PendingDone = 3,
+}
+
+public readonly struct NotificationEvent
+{
+ public NotificationEventType Type { get; init; }
+ public int Value { get; init; }
+ public long Time { get; init; }
+}
+
+public static class Notifications
+{
+ public const int MaxId = 63;
+
+ /// True when this platform has a backend (Android, iOS).
+#if __ANDROID__ || __IOS__
+ public const bool Available = true;
+#else
+ public const bool Available = false;
+#endif
+
+ /// Raised from within , on the polling thread.
+ public static event Action? OnEvent;
+
+ static readonly Queue _stubQueue = new();
+
+ public static void Init()
+ {
+#if __ANDROID__ || __IOS__
+ SokolNotifications.Init();
+#endif
+ }
+
+ /// Android: the channel name the user sees in the system settings, and an optional small
+ /// icon — a path inside the APK's assets, a white silhouette on transparency (null = the app icon).
+ /// No-op on iOS.
+ public static void Configure(string channelName, string? smallIconAsset = null)
+ {
+#if __ANDROID__ || __IOS__
+ SokolNotifications.Configure(channelName, smallIconAsset);
+#endif
+ }
+
+ public static NotificationPermission Permission
+ {
+ get
+ {
+#if __ANDROID__ || __IOS__
+ return (NotificationPermission)SokolNotifications.Status();
+#else
+ return NotificationPermission.Denied;
+#endif
+ }
+ }
+
+ /// Ask the user (Android 13+, iOS) → a event.
+ /// Answers at once when the outcome is already known.
+ public static void RequestPermission()
+ {
+#if __ANDROID__ || __IOS__
+ SokolNotifications.RequestPermission();
+#else
+ _stubQueue.Enqueue(new NotificationEvent {
+ Type = NotificationEventType.Permission, Value = (int)NotificationPermission.Denied });
+#endif
+ }
+
+ /// Show / at .
+ /// Android may run late (Doze); past the notification is dropped
+ /// instead. iOS fires on time.
+ public static void Schedule(int id, DateTimeOffset at, DateTimeOffset? expires, string title, string body)
+ {
+ if (id < 1 || id > MaxId) throw new ArgumentOutOfRangeException(nameof(id));
+#if __ANDROID__ || __IOS__
+ SokolNotifications.Schedule(id, at.ToUnixTimeSeconds(), expires?.ToUnixTimeSeconds() ?? 0, title, body);
+#endif
+ }
+
+ /// Cancel the pending notification with this id and remove it if already shown.
+ public static void Cancel(int id)
+ {
+#if __ANDROID__ || __IOS__
+ SokolNotifications.Cancel(id);
+#endif
+ }
+
+ /// Cancel every pending notification of this plugin and remove the shown ones.
+ public static void CancelAll()
+ {
+#if __ANDROID__ || __IOS__
+ SokolNotifications.CancelAll();
+#endif
+ }
+
+ /// List what is pending → events, then
+ /// .
+ public static void QueryPending()
+ {
+#if __ANDROID__ || __IOS__
+ SokolNotifications.QueryPending();
+#else
+ _stubQueue.Enqueue(new NotificationEvent { Type = NotificationEventType.PendingDone });
+#endif
+ }
+
+ /// The id of the notification the user tapped to open the app, once; 0 when none.
+ /// Check it at start and whenever the app returns to the foreground.
+ public static int TakeOpened()
+ {
+#if __ANDROID__ || __IOS__
+ return SokolNotifications.TakeOpened();
+#else
+ return 0;
+#endif
+ }
+
+ /// Open this app's notification settings, to switch notifications back on.
+ public static void OpenSettings()
+ {
+#if __ANDROID__ || __IOS__
+ SokolNotifications.OpenSettings();
+#endif
+ }
+
+ /// Drain pending events; call once per frame from the game loop.
+ public static void Poll()
+ {
+ while (_stubQueue.Count > 0)
+ OnEvent?.Invoke(_stubQueue.Dequeue());
+#if __ANDROID__ || __IOS__
+ while (SokolNotifications.PollEvent(out SokolNotifications.Event e))
+ OnEvent?.Invoke(new NotificationEvent {
+ Type = (NotificationEventType)e.type, Value = e.value, Time = e.time });
+#endif
+ }
+}
diff --git a/plugins/Notifications/managed/SokolNotifications.cs b/plugins/Notifications/managed/SokolNotifications.cs
new file mode 100644
index 00000000..b5f2545f
--- /dev/null
+++ b/plugins/Notifications/managed/SokolNotifications.cs
@@ -0,0 +1,61 @@
+#if __ANDROID__ || __IOS__
+#nullable enable
+using System;
+using System.Runtime.InteropServices;
+
+internal static class SokolNotifications
+{
+#if __IOS__
+ const string Lib = "@rpath/sokol_notifications.framework/sokol_notifications";
+#else
+ const string Lib = "sokol_notifications";
+#endif
+
+ /* Mirrors sokolnotify_event in sokol_notifications.h. */
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct Event
+ {
+ public int type;
+ public int value;
+ public long time;
+ }
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_init", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern int Init();
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_configure", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void Configure(
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string channelName,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string? smallIconAsset);
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_status", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern int Status();
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_request_permission", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void RequestPermission();
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_schedule", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void Schedule(int id, long fireUnix, long expireUnix,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string title,
+ [MarshalAs(UnmanagedType.LPUTF8Str)] string body);
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_cancel", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void Cancel(int id);
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_cancel_all", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void CancelAll();
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_query_pending", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void QueryPending();
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_take_opened", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern int TakeOpened();
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_open_settings", CallingConvention = CallingConvention.Cdecl)]
+ internal static extern void OpenSettings();
+
+ [DllImport(Lib, EntryPoint = "sokolnotify_poll_event", CallingConvention = CallingConvention.Cdecl)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ internal static extern bool PollEvent(out Event evt);
+}
+#endif
diff --git a/plugins/Notifications/native/CMakeLists.txt b/plugins/Notifications/native/CMakeLists.txt
new file mode 100644
index 00000000..fd90496e
--- /dev/null
+++ b/plugins/Notifications/native/CMakeLists.txt
@@ -0,0 +1,106 @@
+cmake_minimum_required(VERSION 3.20)
+project(sokol_notifications C)
+
+# sokol_notifications — standalone native local-notifications library.
+# Android (WorkManager, via a Java helper) and iOS (UNUserNotificationCenter) only; desktop/web
+# builds have nothing to build and the managed layer (Notifications.cs) stubs everything out.
+#
+# Android build (output → plugins/Notifications/libs/android///):
+# cmake -S . -B build-android- \
+# -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
+# -DANDROID_ABI=arm64-v8a \
+# -DANDROID_PLATFORM=android-26
+#
+# iOS build (output → plugins/Notifications/libs/ios///):
+# cmake -S . -B build-ios-device -G Xcode \
+# -DCMAKE_SYSTEM_NAME=iOS \
+# -DCMAKE_OSX_ARCHITECTURES=arm64 \
+# -DCMAKE_OSX_SYSROOT=iphoneos \
+# -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0
+
+set(PLUGIN_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..") # plugins/Notifications/
+set(LIB_NAME "sokol_notifications")
+
+# ── Desktop/web early-out ─────────────────────────────────────────────────────
+if(NOT ANDROID AND NOT (CMAKE_SYSTEM_NAME STREQUAL "iOS"))
+ message(STATUS
+ "sokol_notifications: no backend on ${CMAKE_SYSTEM_NAME} — managed layer stubs "
+ "notifications out. Nothing to build.")
+ return()
+endif()
+
+# ── Android ───────────────────────────────────────────────────────────────────
+if(ANDROID)
+ add_library(${LIB_NAME} SHARED
+ sokol_notifications_android.c
+ sokol_notifications_queue.c)
+ target_include_directories(${LIB_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+ target_compile_options(${LIB_NAME} PRIVATE -x c)
+ target_link_libraries(${LIB_NAME} PRIVATE android log)
+
+ # Link against the pre-built libsokol.so (for sapp_android_get_native_activity).
+ if(NOT DEFINED SOKOL_LIB_DIR)
+ set(SOKOL_LIB_DIR
+ "${CMAKE_CURRENT_SOURCE_DIR}/../../../libs/android/${ANDROID_ABI}/release")
+ endif()
+
+ set(SOKOL_SO "${SOKOL_LIB_DIR}/libsokol.so")
+ if(NOT EXISTS "${SOKOL_SO}")
+ message(FATAL_ERROR
+ "sokol_notifications: libsokol.so not found at '${SOKOL_SO}'.\n"
+ "Build the main sokol library first:\n"
+ " ./scripts/build-android-sokol-libraries.sh")
+ endif()
+
+ add_library(sokol SHARED IMPORTED)
+ set_target_properties(sokol PROPERTIES IMPORTED_LOCATION "${SOKOL_SO}")
+ target_link_libraries(${LIB_NAME} PRIVATE sokol)
+
+ set(ANDROID_OUT "${PLUGIN_ROOT}/libs/android/${ANDROID_ABI}/$>")
+ add_custom_command(TARGET ${LIB_NAME} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${ANDROID_OUT}"
+ COMMAND ${CMAKE_COMMAND} -E copy
+ "$"
+ "${ANDROID_OUT}/$"
+ COMMENT "Copying lib${LIB_NAME}.so → ${ANDROID_OUT}"
+ )
+
+# ── iOS ───────────────────────────────────────────────────────────────────────
+elseif(CMAKE_SYSTEM_NAME STREQUAL "iOS")
+ enable_language(OBJC)
+
+ add_library(${LIB_NAME} SHARED
+ sokol_notifications_ios.m
+ sokol_notifications_queue.c)
+ target_include_directories(${LIB_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
+ target_compile_options(${LIB_NAME} PRIVATE $<$:-fobjc-arc>)
+ target_link_libraries(${LIB_NAME} PRIVATE
+ "-framework Foundation" "-framework UIKit" "-framework UserNotifications")
+
+ set_target_properties(${LIB_NAME} PROPERTIES
+ FRAMEWORK TRUE
+ MACOSX_FRAMEWORK_IDENTIFIER com.sokolnet.plugins.notifications
+ FRAMEWORK_VERSION A
+ XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "1,2"
+ XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC YES
+ )
+
+ if(CMAKE_OSX_SYSROOT MATCHES "iphonesimulator")
+ if(CMAKE_OSX_ARCHITECTURES MATCHES "x86_64")
+ set(IOS_ARCH_DIR "simulator-x64")
+ else()
+ set(IOS_ARCH_DIR "simulator-arm64")
+ endif()
+ else()
+ set(IOS_ARCH_DIR "arm64")
+ endif()
+
+ set(IOS_OUT "${PLUGIN_ROOT}/libs/ios/${IOS_ARCH_DIR}/$>")
+ add_custom_command(TARGET ${LIB_NAME} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${IOS_OUT}"
+ COMMAND ${CMAKE_COMMAND} -E copy_directory
+ "$"
+ "${IOS_OUT}/${LIB_NAME}.framework"
+ COMMENT "Copying ${LIB_NAME}.framework → ${IOS_OUT}"
+ )
+endif()
diff --git a/plugins/Notifications/native/sokol_notifications.h b/plugins/Notifications/native/sokol_notifications.h
new file mode 100644
index 00000000..509593c8
--- /dev/null
+++ b/plugins/Notifications/native/sokol_notifications.h
@@ -0,0 +1,86 @@
+/* sokol_notifications.h -- local (on-device) scheduled notifications for Sokol.NET apps.
+ Android: WorkManager one-time work posts the notification (via a Java helper class).
+ iOS: UNUserNotificationCenter with a calendar trigger (Objective-C).
+
+ No push, no server: the app schedules a notification with its own text and the OS shows it.
+ The plugin holds no text of its own -- title and body come from the app, already localized.
+
+ Ids are small integers chosen by the app, 1..63 (iOS keeps at most 64 pending requests per
+ app). Scheduling an id that is already pending replaces it.
+
+ Asynchronous answers (permission result, pending list) surface as queued events drained by
+ sokolnotify_poll_event() -- call it from the game thread once per frame.
+*/
+#pragma once
+#ifndef SOKOL_NOTIFICATIONS_H
+#define SOKOL_NOTIFICATIONS_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define SOKOLNOTIFY_MAX_ID 63
+
+typedef enum sokolnotify_status_t {
+ SOKOLNOTIFY_STATUS_NOT_DETERMINED = 0, /* never asked: request_permission() may show a prompt */
+ SOKOLNOTIFY_STATUS_GRANTED = 1,
+ SOKOLNOTIFY_STATUS_DENIED = 2, /* refused, or switched off in the system settings */
+} sokolnotify_status_t;
+
+typedef enum sokolnotify_event_type {
+ SOKOLNOTIFY_EVENT_NONE = 0,
+ SOKOLNOTIFY_EVENT_PERMISSION = 1, /* value = sokolnotify_status_t after the request */
+ SOKOLNOTIFY_EVENT_PENDING = 2, /* value = id, time = fire time (unix seconds, UTC) */
+ SOKOLNOTIFY_EVENT_PENDING_DONE = 3, /* end of the list started by sokolnotify_query_pending */
+} sokolnotify_event_type;
+
+typedef struct sokolnotify_event {
+ int type; /* sokolnotify_event_type */
+ int value;
+ long long time;
+} sokolnotify_event;
+
+/* Connect to the platform. Returns 1 when notifications are available. Call once at app start. */
+int sokolnotify_init(void);
+
+/* Android: the notification channel's user-visible name (shown in the system settings) and an
+ optional small icon, a path inside the APK's assets (a white silhouette on transparency; NULL =
+ the app icon). Used for notifications scheduled after this call. No-op on iOS. */
+void sokolnotify_configure(const char* channel_name, const char* small_icon_asset);
+
+/* Current permission, sokolnotify_status_t. Synchronous; on iOS the value is refreshed at
+ launch, on every return to the foreground and after a request. */
+int sokolnotify_status(void);
+
+/* Ask the user (Android 13+: POST_NOTIFICATIONS; iOS: alert + sound) -> PERMISSION event.
+ Answers at once when the outcome is already known (granted, or no prompt possible). */
+void sokolnotify_request_permission(void);
+
+/* Show (title, body) at fire_unix (UTC seconds). Android may run late (Doze); when it would run
+ after expire_unix (> 0) the notification is dropped instead. iOS fires on time. */
+void sokolnotify_schedule(int id, long long fire_unix, long long expire_unix,
+ const char* title, const char* body);
+
+/* Cancel one id / every id: the pending notification and, if shown, the shown one. */
+void sokolnotify_cancel(int id);
+void sokolnotify_cancel_all(void);
+
+/* List what is pending -> zero or more PENDING events, then PENDING_DONE. */
+void sokolnotify_query_pending(void);
+
+/* The id of the notification the user tapped to open the app, once; 0 when there is none.
+ Poll it at start and whenever the app returns to the foreground. */
+int sokolnotify_take_opened(void);
+
+/* Open this app's notification settings (to switch notifications back on after a denial). */
+void sokolnotify_open_settings(void);
+
+/* Pop one queued event. Returns false when the queue is empty. */
+bool sokolnotify_poll_event(sokolnotify_event* out);
+
+#ifdef __cplusplus
+}
+#endif
+#endif /* SOKOL_NOTIFICATIONS_H */
diff --git a/plugins/Notifications/native/sokol_notifications_android.c b/plugins/Notifications/native/sokol_notifications_android.c
new file mode 100644
index 00000000..17c31fe1
--- /dev/null
+++ b/plugins/Notifications/native/sokol_notifications_android.c
@@ -0,0 +1,171 @@
+/* sokol_notifications_android.c -- local notifications bridge for Sokol.NET.
+ The work lives in the Java helper com.sokol.notifications.SokolNotifications
+ (platform/android/java/, wired via AndroidJavaSource_sokolnotificationsPath);
+ this file forwards calls to it and receives events via nativeOnEvent.
+ Requires:
+ - sapp_android_get_native_activity() exported from sokol_app.h
+ - androidx.work:work-runtime in app/build.gradle (gradle-deps.txt)
+*/
+#include "sokol_notifications.h"
+#include
+#include
+#include
+
+/* Already declared in sokol_app.h; returns ANativeActivity* as const void*. */
+extern const void* sapp_android_get_native_activity(void);
+
+/* sokol_notifications_queue.c */
+extern void sokolnotify__emit(int type, int value, long long time);
+
+static jclass _sn_class; /* global ref to com.sokol.notifications.SokolNotifications */
+static jmethodID _sn_init;
+static jmethodID _sn_configure;
+static jmethodID _sn_status;
+static jmethodID _sn_request;
+static jmethodID _sn_schedule;
+static jmethodID _sn_cancel;
+static jmethodID _sn_cancel_all;
+static jmethodID _sn_query;
+static jmethodID _sn_take_opened;
+static jmethodID _sn_open_settings;
+
+static JNIEnv* _sn_env(void)
+{
+ ANativeActivity* activity = (ANativeActivity*)sapp_android_get_native_activity();
+ if (!activity) return NULL;
+ JNIEnv* env = NULL;
+ (*activity->vm)->AttachCurrentThread(activity->vm, &env, NULL);
+ /* Do NOT DetachCurrentThread — Sokol reuses this thread across frames. */
+ return env;
+}
+
+/* Load the helper class through the activity's ClassLoader (FindClass from a
+ native thread uses the bootstrap loader and cannot see app classes). */
+static bool _sn_resolve(JNIEnv* env, ANativeActivity* activity)
+{
+ if (_sn_class) return true;
+
+ jclass clsLdrCls = (*env)->FindClass(env, "java/lang/ClassLoader");
+ jmethodID loadClass = (*env)->GetMethodID(env, clsLdrCls, "loadClass",
+ "(Ljava/lang/String;)Ljava/lang/Class;");
+ jclass actCls = (*env)->GetObjectClass(env, activity->clazz);
+ jmethodID getClsLdr = (*env)->GetMethodID(env, actCls, "getClassLoader",
+ "()Ljava/lang/ClassLoader;");
+ jobject loader = (*env)->CallObjectMethod(env, activity->clazz, getClsLdr);
+ jstring name = (*env)->NewStringUTF(env, "com.sokol.notifications.SokolNotifications");
+ jclass cls = (jclass)(*env)->CallObjectMethod(env, loader, loadClass, name);
+
+ (*env)->DeleteLocalRef(env, name);
+ (*env)->DeleteLocalRef(env, loader);
+ (*env)->DeleteLocalRef(env, actCls);
+ (*env)->DeleteLocalRef(env, clsLdrCls);
+
+ if (!cls || (*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionClear(env);
+ return false;
+ }
+
+ _sn_class = (jclass)(*env)->NewGlobalRef(env, cls);
+ _sn_init = (*env)->GetStaticMethodID(env, _sn_class, "init", "(Landroid/app/Activity;)V");
+ _sn_configure = (*env)->GetStaticMethodID(env, _sn_class, "configure",
+ "(Ljava/lang/String;Ljava/lang/String;)V");
+ _sn_status = (*env)->GetStaticMethodID(env, _sn_class, "status", "()I");
+ _sn_request = (*env)->GetStaticMethodID(env, _sn_class, "requestPermission", "()V");
+ _sn_schedule = (*env)->GetStaticMethodID(env, _sn_class, "schedule",
+ "(IJJLjava/lang/String;Ljava/lang/String;)V");
+ _sn_cancel = (*env)->GetStaticMethodID(env, _sn_class, "cancel", "(I)V");
+ _sn_cancel_all = (*env)->GetStaticMethodID(env, _sn_class, "cancelAll", "()V");
+ _sn_query = (*env)->GetStaticMethodID(env, _sn_class, "queryPending", "()V");
+ _sn_take_opened = (*env)->GetStaticMethodID(env, _sn_class, "takeOpened", "()I");
+ _sn_open_settings = (*env)->GetStaticMethodID(env, _sn_class, "openSettings", "()V");
+ (*env)->DeleteLocalRef(env, cls);
+ if ((*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionClear(env);
+ return false;
+ }
+ return true;
+}
+
+int sokolnotify_init(void)
+{
+ ANativeActivity* activity = (ANativeActivity*)sapp_android_get_native_activity();
+ JNIEnv* env = _sn_env();
+ if (!activity || !env || !_sn_resolve(env, activity)) return 0;
+ (*env)->CallStaticVoidMethod(env, _sn_class, _sn_init, activity->clazz);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); return 0; }
+ return 1;
+}
+
+static void _sn_call_void(jmethodID method)
+{
+ if (!_sn_class || !method) return;
+ JNIEnv* env = _sn_env();
+ if (!env) return;
+ (*env)->CallStaticVoidMethod(env, _sn_class, method);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+}
+
+static int _sn_call_int(jmethodID method)
+{
+ if (!_sn_class || !method) return 0;
+ JNIEnv* env = _sn_env();
+ if (!env) return 0;
+ jint r = (*env)->CallStaticIntMethod(env, _sn_class, method);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); return 0; }
+ return (int)r;
+}
+
+static jstring _sn_str(JNIEnv* env, const char* s) { return s ? (*env)->NewStringUTF(env, s) : NULL; }
+
+void sokolnotify_configure(const char* channel_name, const char* small_icon_asset)
+{
+ if (!_sn_class) return;
+ JNIEnv* env = _sn_env();
+ if (!env) return;
+ jstring name = _sn_str(env, channel_name);
+ jstring icon = _sn_str(env, small_icon_asset);
+ (*env)->CallStaticVoidMethod(env, _sn_class, _sn_configure, name, icon);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ if (name) (*env)->DeleteLocalRef(env, name);
+ if (icon) (*env)->DeleteLocalRef(env, icon);
+}
+
+int sokolnotify_status(void) { return _sn_call_int(_sn_status); }
+void sokolnotify_request_permission(void) { _sn_call_void(_sn_request); }
+void sokolnotify_cancel_all(void) { _sn_call_void(_sn_cancel_all); }
+void sokolnotify_query_pending(void) { _sn_call_void(_sn_query); }
+int sokolnotify_take_opened(void) { return _sn_call_int(_sn_take_opened); }
+void sokolnotify_open_settings(void) { _sn_call_void(_sn_open_settings); }
+
+void sokolnotify_schedule(int id, long long fire_unix, long long expire_unix,
+ const char* title, const char* body)
+{
+ if (!_sn_class || id < 1 || id > SOKOLNOTIFY_MAX_ID) return;
+ JNIEnv* env = _sn_env();
+ if (!env) return;
+ jstring jt = _sn_str(env, title);
+ jstring jb = _sn_str(env, body);
+ (*env)->CallStaticVoidMethod(env, _sn_class, _sn_schedule, (jint)id,
+ (jlong)fire_unix, (jlong)expire_unix, jt, jb);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ if (jt) (*env)->DeleteLocalRef(env, jt);
+ if (jb) (*env)->DeleteLocalRef(env, jb);
+}
+
+void sokolnotify_cancel(int id)
+{
+ if (!_sn_class) return;
+ JNIEnv* env = _sn_env();
+ if (!env) return;
+ (*env)->CallStaticVoidMethod(env, _sn_class, _sn_cancel, (jint)id);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+}
+
+/* Upcall from com.sokol.notifications.SokolNotifications (any Java thread). */
+JNIEXPORT void JNICALL
+Java_com_sokol_notifications_SokolNotifications_nativeOnEvent(JNIEnv* env, jclass cls,
+ jint type, jint value, jlong time)
+{
+ (void)env; (void)cls;
+ sokolnotify__emit((int)type, (int)value, (long long)time);
+}
diff --git a/plugins/Notifications/native/sokol_notifications_ios.m b/plugins/Notifications/native/sokol_notifications_ios.m
new file mode 100644
index 00000000..aa501d64
--- /dev/null
+++ b/plugins/Notifications/native/sokol_notifications_ios.m
@@ -0,0 +1,195 @@
+/* sokol_notifications_ios.m -- UNUserNotificationCenter implementation for the sokol_notifications
+ plugin. Events flow into the shared C queue (sokol_notifications_queue.c, linked into this
+ framework), so sokolnotify_poll_event works identically on both platforms.
+
+ Local notifications need no entitlement, no APNs and no UIBackgroundModes. A calendar trigger
+ fires at the wall-clock time and survives reboots.
+
+ Objective-C rather than Swift for one reason: a tap that COLD-STARTS the app is delivered only to
+ a delegate assigned before the app finishes launching -- long before the app's own code runs.
+ The constructor below arranges that without touching the host's UIApplicationDelegate. */
+
+#import
+#import
+#import
+#include
+#include "sokol_notifications.h"
+
+/* sokol_notifications_queue.c */
+extern void sokolnotify__emit(int type, int value, long long time);
+
+static NSString* const kPrefix = @"sokolnotify-";
+static NSString* const kAtKey = @"sokolnotify_at";
+
+static atomic_int _status = SOKOLNOTIFY_STATUS_NOT_DETERMINED;
+static atomic_int _opened = 0;
+
+static int _sn_id_of(NSString* identifier)
+{
+ if (![identifier hasPrefix:kPrefix]) return 0;
+ return [[identifier substringFromIndex:kPrefix.length] intValue];
+}
+
+static NSArray* _sn_all_identifiers(void)
+{
+ NSMutableArray* ids = [NSMutableArray arrayWithCapacity:SOKOLNOTIFY_MAX_ID];
+ for (int i = 1; i <= SOKOLNOTIFY_MAX_ID; i++) [ids addObject:[NSString stringWithFormat:@"%@%d", kPrefix, i]];
+ return ids;
+}
+
+static int _sn_map_status(UNAuthorizationStatus s)
+{
+ switch (s) {
+ case UNAuthorizationStatusNotDetermined: return SOKOLNOTIFY_STATUS_NOT_DETERMINED;
+ case UNAuthorizationStatusDenied: return SOKOLNOTIFY_STATUS_DENIED;
+ default: return SOKOLNOTIFY_STATUS_GRANTED; /* authorized, provisional, ephemeral */
+ }
+}
+
+static void _sn_refresh_status(void)
+{
+ [[UNUserNotificationCenter currentNotificationCenter]
+ getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* s) {
+ atomic_store(&_status, _sn_map_status(s.authorizationStatus));
+ }];
+}
+
+@interface SokolNotifyDelegate : NSObject
+@end
+
+@implementation SokolNotifyDelegate
+/* The app is in front: the user is already here, so nothing is shown. */
+- (void)userNotificationCenter:(UNUserNotificationCenter*)center
+ willPresentNotification:(UNNotification*)notification
+ withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
+{
+ completionHandler(UNNotificationPresentationOptionNone);
+}
+
+- (void)userNotificationCenter:(UNUserNotificationCenter*)center
+didReceiveNotificationResponse:(UNNotificationResponse*)response
+ withCompletionHandler:(void (^)(void))completionHandler
+{
+ int id = _sn_id_of(response.notification.request.identifier);
+ if (id > 0) atomic_store(&_opened, id);
+ completionHandler();
+}
+@end
+
+static SokolNotifyDelegate* _delegate;
+
+static void _sn_install_delegate(void)
+{
+ UNUserNotificationCenter* c = [UNUserNotificationCenter currentNotificationCenter];
+ if (!_delegate) _delegate = [SokolNotifyDelegate new];
+ if (c.delegate == nil) c.delegate = _delegate; /* never replace a host's own delegate */
+}
+
+/* Runs when the framework loads (it is linked into the app, so: at launch, before main). The
+ observers fire inside UIKit's launch sequence, before a cold-start tap is delivered. */
+__attribute__((constructor))
+static void _sn_boot(void)
+{
+ NSNotificationCenter* nc = [NSNotificationCenter defaultCenter];
+ [nc addObserverForName:UIApplicationDidFinishLaunchingNotification object:nil queue:nil
+ usingBlock:^(NSNotification* n) { _sn_install_delegate(); _sn_refresh_status(); }];
+ [nc addObserverForName:UIApplicationDidBecomeActiveNotification object:nil queue:nil
+ usingBlock:^(NSNotification* n) { _sn_refresh_status(); }];
+}
+
+int sokolnotify_init(void)
+{
+ dispatch_async(dispatch_get_main_queue(), ^{ _sn_install_delegate(); });
+ _sn_refresh_status();
+ return 1;
+}
+
+void sokolnotify_configure(const char* channel_name, const char* small_icon_asset)
+{
+ (void)channel_name; (void)small_icon_asset; /* Android only */
+}
+
+int sokolnotify_status(void) { return atomic_load(&_status); }
+
+void sokolnotify_request_permission(void)
+{
+ [[UNUserNotificationCenter currentNotificationCenter]
+ requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound)
+ completionHandler:^(BOOL granted, NSError* error) {
+ /* Already refused: iOS answers NO at once without showing anything. */
+ int st = granted ? SOKOLNOTIFY_STATUS_GRANTED : SOKOLNOTIFY_STATUS_DENIED;
+ atomic_store(&_status, st);
+ sokolnotify__emit(SOKOLNOTIFY_EVENT_PERMISSION, st, 0);
+ }];
+}
+
+void sokolnotify_schedule(int id, long long fire_unix, long long expire_unix,
+ const char* title, const char* body)
+{
+ (void)expire_unix; /* iOS fires on time */
+ if (id < 1 || id > SOKOLNOTIFY_MAX_ID) return;
+
+ UNMutableNotificationContent* content = [UNMutableNotificationContent new];
+ content.title = title ? [NSString stringWithUTF8String:title] : @"";
+ content.body = body ? [NSString stringWithUTF8String:body] : @"";
+ content.sound = [UNNotificationSound defaultSound];
+ content.userInfo = @{ kAtKey: @(fire_unix) };
+
+ /* Local wall-clock components, so the reminder keeps its local time across a zone change. */
+ NSDate* when = [NSDate dateWithTimeIntervalSince1970:(NSTimeInterval)fire_unix];
+ NSDateComponents* dc = [[NSCalendar currentCalendar]
+ components:(NSCalendarUnitYear | NSCalendarUnitMonth | NSCalendarUnitDay |
+ NSCalendarUnitHour | NSCalendarUnitMinute | NSCalendarUnitSecond)
+ fromDate:when];
+ UNCalendarNotificationTrigger* trigger =
+ [UNCalendarNotificationTrigger triggerWithDateMatchingComponents:dc repeats:NO];
+
+ NSString* identifier = [NSString stringWithFormat:@"%@%d", kPrefix, id];
+ UNNotificationRequest* req = [UNNotificationRequest requestWithIdentifier:identifier
+ content:content
+ trigger:trigger];
+ /* Same identifier = replaces the pending one. */
+ [[UNUserNotificationCenter currentNotificationCenter] addNotificationRequest:req withCompletionHandler:nil];
+}
+
+void sokolnotify_cancel(int id)
+{
+ NSArray* ids = @[ [NSString stringWithFormat:@"%@%d", kPrefix, id] ];
+ UNUserNotificationCenter* c = [UNUserNotificationCenter currentNotificationCenter];
+ [c removePendingNotificationRequestsWithIdentifiers:ids];
+ [c removeDeliveredNotificationsWithIdentifiers:ids];
+}
+
+void sokolnotify_cancel_all(void)
+{
+ /* By explicit identifiers, not a get-then-filter round trip: that would complete AFTER a
+ schedule() issued right behind this call and remove the new request too. */
+ NSArray* ids = _sn_all_identifiers();
+ UNUserNotificationCenter* c = [UNUserNotificationCenter currentNotificationCenter];
+ [c removePendingNotificationRequestsWithIdentifiers:ids];
+ [c removeDeliveredNotificationsWithIdentifiers:ids];
+}
+
+void sokolnotify_query_pending(void)
+{
+ [[UNUserNotificationCenter currentNotificationCenter]
+ getPendingNotificationRequestsWithCompletionHandler:^(NSArray* requests) {
+ for (UNNotificationRequest* r in requests) {
+ int id = _sn_id_of(r.identifier);
+ if (id <= 0) continue;
+ NSNumber* at = r.content.userInfo[kAtKey];
+ sokolnotify__emit(SOKOLNOTIFY_EVENT_PENDING, id, at ? at.longLongValue : 0);
+ }
+ sokolnotify__emit(SOKOLNOTIFY_EVENT_PENDING_DONE, 0, 0);
+ }];
+}
+
+int sokolnotify_take_opened(void) { return atomic_exchange(&_opened, 0); }
+
+void sokolnotify_open_settings(void)
+{
+ dispatch_async(dispatch_get_main_queue(), ^{
+ NSURL* url = [NSURL URLWithString:UIApplicationOpenSettingsURLString];
+ if (url) [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
+ });
+}
diff --git a/plugins/Notifications/native/sokol_notifications_queue.c b/plugins/Notifications/native/sokol_notifications_queue.c
new file mode 100644
index 00000000..afe806ed
--- /dev/null
+++ b/plugins/Notifications/native/sokol_notifications_queue.c
@@ -0,0 +1,43 @@
+/* sokol_notifications_queue.c -- lock-protected event ring shared by the Android and iOS
+ implementations. Platform code emits with sokolnotify__emit() (any thread); the game thread
+ drains with sokolnotify_poll_event(). Events carry no strings. */
+#include "sokol_notifications.h"
+
+#include
+
+#define SOKOLNOTIFY_QUEUE_CAP 80 /* a full pending list (63) + DONE + a permission answer */
+
+static pthread_mutex_t _sn_lock = PTHREAD_MUTEX_INITIALIZER;
+static sokolnotify_event _sn_ring[SOKOLNOTIFY_QUEUE_CAP];
+static int _sn_head = 0;
+static int _sn_count = 0;
+
+/* Internal, called by the platform shims. Drops the event if the ring is full
+ (the app stopped polling -- nothing better to do). */
+void sokolnotify__emit(int type, int value, long long time)
+{
+ pthread_mutex_lock(&_sn_lock);
+ if (_sn_count < SOKOLNOTIFY_QUEUE_CAP) {
+ sokolnotify_event* e = &_sn_ring[(_sn_head + _sn_count) % SOKOLNOTIFY_QUEUE_CAP];
+ e->type = type;
+ e->value = value;
+ e->time = time;
+ _sn_count++;
+ }
+ pthread_mutex_unlock(&_sn_lock);
+}
+
+bool sokolnotify_poll_event(sokolnotify_event* out)
+{
+ if (!out) return false;
+ pthread_mutex_lock(&_sn_lock);
+ if (_sn_count == 0) {
+ pthread_mutex_unlock(&_sn_lock);
+ return false;
+ }
+ *out = _sn_ring[_sn_head];
+ _sn_head = (_sn_head + 1) % SOKOLNOTIFY_QUEUE_CAP;
+ _sn_count--;
+ pthread_mutex_unlock(&_sn_lock);
+ return true;
+}
diff --git a/plugins/Notifications/platform/android/gradle-deps.txt b/plugins/Notifications/platform/android/gradle-deps.txt
new file mode 100644
index 00000000..6cf0d241
--- /dev/null
+++ b/plugins/Notifications/platform/android/gradle-deps.txt
@@ -0,0 +1,3 @@
+# WorkManager: one-time work posts each scheduled notification; it survives reboots and app
+# updates by itself (no boot receiver, no exact-alarm permission).
+androidx.work:work-runtime:2.10.5
diff --git a/plugins/Notifications/platform/android/java/com/sokol/notifications/SokolNotificationWorker.java b/plugins/Notifications/platform/android/java/com/sokol/notifications/SokolNotificationWorker.java
new file mode 100644
index 00000000..307b987d
--- /dev/null
+++ b/plugins/Notifications/platform/android/java/com/sokol/notifications/SokolNotificationWorker.java
@@ -0,0 +1,31 @@
+package com.sokol.notifications;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.work.Data;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+/**
+ * Posts one scheduled notification. Runs in the app process, possibly started for this alone (no
+ * activity, no native code): everything it needs is in the request's input Data.
+ */
+public final class SokolNotificationWorker extends Worker {
+
+ public SokolNotificationWorker(@NonNull Context context, @NonNull WorkerParameters params) {
+ super(context, params);
+ }
+
+ @NonNull
+ @Override
+ public Result doWork() {
+ Data d = getInputData();
+ long expire = d.getLong("expire", 0);
+ // Doze or a battery saver ran this late: a stale reminder (e.g. at night) is dropped.
+ if (expire > 0 && System.currentTimeMillis() > expire * 1000L) return Result.success();
+ SokolNotifications.post(getApplicationContext(), d.getInt("id", 0), d.getString("title"),
+ d.getString("body"), d.getString("channel"), d.getString("icon"));
+ return Result.success();
+ }
+}
diff --git a/plugins/Notifications/platform/android/java/com/sokol/notifications/SokolNotifications.java b/plugins/Notifications/platform/android/java/com/sokol/notifications/SokolNotifications.java
new file mode 100644
index 00000000..f22b9183
--- /dev/null
+++ b/plugins/Notifications/platform/android/java/com/sokol/notifications/SokolNotifications.java
@@ -0,0 +1,273 @@
+package com.sokol.notifications;
+
+import android.app.Activity;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.drawable.Icon;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.service.notification.StatusBarNotification;
+
+import androidx.work.Data;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.WorkInfo;
+import androidx.work.WorkManager;
+
+import java.io.InputStream;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Local notifications helper for the sokol_notifications plugin. Called from native code
+ * (sokol_notifications_android.c); asynchronous answers flow back through nativeOnEvent into the
+ * plugin's C event queue, drained by the app's game thread.
+ *
+ * Scheduling is WorkManager one-time work: it survives reboots and app updates by itself, with no
+ * boot receiver and no exact-alarm permission. The time is approximate (Doze may run it late),
+ * which is why each request carries an expiry after which {@link SokolNotificationWorker} drops it.
+ * Everything the Worker needs travels in the request's input Data, so it runs without app code —
+ * also when the app process was started only to run it.
+ */
+public final class SokolNotifications {
+
+ /** Upcall into sokol_notifications_android.c — safe from any thread. */
+ static native void nativeOnEvent(int type, int value, long time);
+
+ /* Mirror of sokolnotify_event_type / sokolnotify_status_t in sokol_notifications.h. */
+ static final int EV_PERMISSION = 1;
+ static final int EV_PENDING = 2;
+ static final int EV_PENDING_DONE = 3;
+ static final int ST_NOT_DETERMINED = 0;
+ static final int ST_GRANTED = 1;
+ static final int ST_DENIED = 2;
+
+ static final String TAG = "sokolnotify"; // WorkManager tag + notification tag
+ static final String TAG_AT = "sokolnotify-at-"; // + fire time, read back by queryPending
+ static final String TAG_ID = "sokolnotify-id-"; // + id, read back by queryPending
+ static final String CHANNEL_ID = "sokolnotify";
+ static final String EXTRA_ID = "sokol_notify_id";
+ /* A distinct action, so a tap on a running app is not mistaken for a launcher tap: an intent
+ equal to the task's root intent only brings the task forward and never reaches onNewIntent. */
+ static final String ACTION_OPEN = "com.sokol.notifications.OPEN";
+ static final String PERMISSION = "android.permission.POST_NOTIFICATIONS";
+
+ static Activity activity;
+ static Context appContext;
+ static volatile String channelName = "Notifications";
+ static volatile String smallIconAsset;
+
+ private SokolNotifications() {}
+
+ public static void init(Activity act) {
+ activity = act;
+ appContext = act.getApplicationContext();
+ }
+
+ public static void configure(String name, String iconAsset) {
+ if (name != null && !name.isEmpty()) channelName = name;
+ smallIconAsset = iconAsset;
+ // Renames the channel the user sees in the system settings (language change).
+ ensureChannel(appContext, channelName);
+ }
+
+ public static int status() {
+ if (appContext == null) return ST_DENIED;
+ NotificationManager nm = appContext.getSystemService(NotificationManager.class);
+ if (nm != null && nm.areNotificationsEnabled()) return ST_GRANTED;
+ // Below API 33 there is no runtime prompt: off means the user switched it off in settings.
+ if (Build.VERSION.SDK_INT < 33) return ST_DENIED;
+ // Not granted yet. Android cannot tell "never asked" from "refused twice" (then it shows no
+ // dialog at all); requestPermission() finds out and reports DENIED in the second case.
+ return appContext.checkSelfPermission(PERMISSION) == PackageManager.PERMISSION_GRANTED
+ ? ST_DENIED // permission granted but notifications switched off in settings
+ : ST_NOT_DETERMINED;
+ }
+
+ private static final int PERM_POLL_MS = 250;
+ private static final int PERM_SHOWN_MS = 1500; // slow phones take a moment to raise it
+ private static final int PERM_POLL_MAX = 240; // 60 s, for a dialog left open in the background
+
+ /**
+ * Raise the POST_NOTIFICATIONS dialog (API 33+) and report the outcome as EV_PERMISSION.
+ * The grant result goes to the Activity's onRequestPermissionsResult, which this plugin does not
+ * own, so the answer is POLLED on the main looper instead. While the
+ * dialog is up the activity has no window focus; focus back without a grant = refused. Focus
+ * never lost within PERM_SHOWN_MS = Android showed no dialog (refused twice before) = refused.
+ */
+ public static void requestPermission() {
+ final int now = status();
+ if (now != ST_NOT_DETERMINED || activity == null) {
+ nativeOnEvent(EV_PERMISSION, now == ST_GRANTED ? ST_GRANTED : ST_DENIED, 0);
+ return;
+ }
+ final Activity act = activity;
+ final Handler main = new Handler(Looper.getMainLooper());
+ main.post(() -> {
+ try {
+ act.requestPermissions(new String[] { PERMISSION }, 4712);
+ } catch (Throwable t) {
+ nativeOnEvent(EV_PERMISSION, ST_DENIED, 0);
+ return;
+ }
+ main.postDelayed(new Runnable() {
+ int tries = 0;
+ boolean focusLost = false;
+ @Override public void run() {
+ tries++;
+ if (status() == ST_GRANTED) { nativeOnEvent(EV_PERMISSION, ST_GRANTED, 0); return; }
+ boolean focused = act.hasWindowFocus();
+ if (!focused) focusLost = true;
+ boolean answered = focused && (focusLost || tries * PERM_POLL_MS >= PERM_SHOWN_MS);
+ if (answered || tries >= PERM_POLL_MAX) { nativeOnEvent(EV_PERMISSION, ST_DENIED, 0); return; }
+ main.postDelayed(this, PERM_POLL_MS);
+ }
+ }, PERM_POLL_MS);
+ });
+ }
+
+ public static void schedule(int id, long fireUnix, long expireUnix, String title, String body) {
+ if (appContext == null) return;
+ long delayMs = Math.max(0, fireUnix * 1000L - System.currentTimeMillis());
+ Data data = new Data.Builder()
+ .putInt("id", id)
+ .putLong("expire", expireUnix)
+ .putString("title", title)
+ .putString("body", body)
+ .putString("channel", channelName)
+ .putString("icon", smallIconAsset)
+ .build();
+ OneTimeWorkRequest req = new OneTimeWorkRequest.Builder(SokolNotificationWorker.class)
+ .setInitialDelay(delayMs, TimeUnit.MILLISECONDS)
+ .setInputData(data)
+ .addTag(TAG)
+ .addTag(TAG_AT + fireUnix)
+ .addTag(TAG_ID + id)
+ .build();
+ WorkManager.getInstance(appContext).enqueueUniqueWork(TAG + "-" + id, ExistingWorkPolicy.REPLACE, req);
+ }
+
+ public static void cancel(int id) {
+ if (appContext == null) return;
+ WorkManager.getInstance(appContext).cancelUniqueWork(TAG + "-" + id);
+ NotificationManager nm = appContext.getSystemService(NotificationManager.class);
+ if (nm != null) nm.cancel(TAG, id);
+ }
+
+ public static void cancelAll() {
+ if (appContext == null) return;
+ // By tag, never cancelAllWork(): other code in the app may use WorkManager too.
+ WorkManager.getInstance(appContext).cancelAllWorkByTag(TAG);
+ NotificationManager nm = appContext.getSystemService(NotificationManager.class);
+ if (nm == null) return;
+ for (StatusBarNotification sbn : nm.getActiveNotifications())
+ if (TAG.equals(sbn.getTag())) nm.cancel(TAG, sbn.getId());
+ }
+
+ public static void queryPending() {
+ if (appContext == null) { nativeOnEvent(EV_PENDING_DONE, 0, 0); return; }
+ final Context c = appContext;
+ new Thread(() -> {
+ try {
+ List infos = WorkManager.getInstance(c).getWorkInfosByTag(TAG).get();
+ for (WorkInfo wi : infos) {
+ if (wi.getState().isFinished()) continue;
+ int id = 0;
+ long at = 0;
+ for (String t : wi.getTags()) {
+ if (t.startsWith(TAG_AT)) at = Long.parseLong(t.substring(TAG_AT.length()));
+ else if (t.startsWith(TAG_ID)) id = Integer.parseInt(t.substring(TAG_ID.length()));
+ }
+ nativeOnEvent(EV_PENDING, id, at);
+ }
+ } catch (Throwable t) {
+ // fall through: an unreadable list is reported as an empty one
+ }
+ nativeOnEvent(EV_PENDING_DONE, 0, 0);
+ }, "sokolnotify-query").start();
+ }
+
+ /** The id of the notification that opened (or reopened) the activity, once; 0 = none. */
+ public static int takeOpened() {
+ Activity act = activity;
+ if (act == null) return 0;
+ Intent i = act.getIntent(); // the newest intent: SokolNativeActivity.onNewIntent calls setIntent
+ if (i == null || !ACTION_OPEN.equals(i.getAction())) return 0;
+ int id = i.getIntExtra(EXTRA_ID, 0);
+ i.removeExtra(EXTRA_ID);
+ i.setAction(Intent.ACTION_MAIN);
+ return id;
+ }
+
+ public static void openSettings() {
+ Activity act = activity;
+ if (act == null) return;
+ act.runOnUiThread(() -> {
+ try {
+ Intent i = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
+ .putExtra(Settings.EXTRA_APP_PACKAGE, act.getPackageName());
+ act.startActivity(i);
+ } catch (Throwable t) {
+ try {
+ act.startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ android.net.Uri.fromParts("package", act.getPackageName(), null)));
+ } catch (Throwable ignored) { }
+ }
+ });
+ }
+
+ // ── Shared with SokolNotificationWorker (may run without an activity) ─────────────────────
+
+ static void ensureChannel(Context c, String name) {
+ if (c == null) return;
+ NotificationManager nm = c.getSystemService(NotificationManager.class);
+ if (nm == null) return;
+ NotificationChannel ch = new NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT);
+ nm.createNotificationChannel(ch); // creating an existing channel only updates its name
+ }
+
+ static void post(Context c, int id, String title, String body, String channel, String iconAsset) {
+ NotificationManager nm = c.getSystemService(NotificationManager.class);
+ if (nm == null || !nm.areNotificationsEnabled()) return;
+ ensureChannel(c, channel != null ? channel : "Notifications");
+
+ Intent launch = c.getPackageManager().getLaunchIntentForPackage(c.getPackageName());
+ if (launch == null) return;
+ ComponentName target = launch.getComponent();
+ Intent open = new Intent(ACTION_OPEN)
+ .setComponent(target)
+ .putExtra(EXTRA_ID, id)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ PendingIntent pi = PendingIntent.getActivity(c, id, open,
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+
+ Notification.Builder b = new Notification.Builder(c, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(body)
+ .setStyle(new Notification.BigTextStyle().bigText(body))
+ .setContentIntent(pi)
+ .setAutoCancel(true)
+ .setSmallIcon(smallIcon(c, iconAsset));
+ nm.notify(TAG, id, b.build());
+ }
+
+ static Icon smallIcon(Context c, String asset) {
+ if (asset != null && !asset.isEmpty()) {
+ try (InputStream in = c.getAssets().open(asset)) {
+ Bitmap bmp = BitmapFactory.decodeStream(in);
+ if (bmp != null) return Icon.createWithBitmap(bmp);
+ } catch (Throwable ignored) { }
+ }
+ return Icon.createWithResource(c, c.getApplicationInfo().icon);
+ }
+}
diff --git a/plugins/Notifications/scripts/build-android.sh b/plugins/Notifications/scripts/build-android.sh
new file mode 100755
index 00000000..6a762109
--- /dev/null
+++ b/plugins/Notifications/scripts/build-android.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+# Build libsokol_notifications.so for Android — standalone Notifications plugin library.
+# Output: libs/android//{debug,release}/libsokol_notifications.so
+# Run from any directory; the script resolves the repo root automatically.
+#
+# Requires: ANDROID_NDK environment variable pointing to the Android NDK.
+# Usage: ./plugins/Notifications/scripts/build-android.sh
+#
+# The plugin links against the pre-built libsokol.so for each ABI.
+# Build the main sokol library first: ./scripts/build-android-sokol-libraries.sh
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+PLUGIN_NATIVE="$REPO_ROOT/plugins/Notifications/native"
+
+if [ -z "$ANDROID_NDK" ]; then
+ echo "Error: ANDROID_NDK environment variable is not set."
+ echo "Example: export ANDROID_NDK=/path/to/android-ndk"
+ exit 1
+fi
+if [ ! -d "$ANDROID_NDK" ]; then
+ echo "Error: ANDROID_NDK directory not found: $ANDROID_NDK"
+ exit 1
+fi
+
+ANDROID_ABIS=("armeabi-v7a" "arm64-v8a" "x86_64")
+
+echo "========================================="
+echo "sokol_notifications — Android build"
+echo "NDK: $ANDROID_NDK"
+echo "ABIs: ${ANDROID_ABIS[*]}"
+echo "========================================="
+
+for ABI in "${ANDROID_ABIS[@]}"; do
+ echo ""
+ echo "----- ABI: $ABI -----"
+
+ SOKOL_SO="$REPO_ROOT/libs/android/$ABI/release/libsokol.so"
+ if [ ! -f "$SOKOL_SO" ]; then
+ echo "Error: libsokol.so not found at $SOKOL_SO"
+ echo " Run ./scripts/build-android-sokol-libraries.sh first."
+ exit 1
+ fi
+
+ BUILD_DIR="$REPO_ROOT/build-sokol-notifications-android-$ABI"
+ rm -rf "$BUILD_DIR"
+ mkdir -p "$BUILD_DIR"
+
+ # SOKOL_LIB_DIR is auto-derived inside CMakeLists.txt from the plugin's location.
+ cmake -S "$PLUGIN_NATIVE" -B "$BUILD_DIR" \
+ -DCMAKE_TOOLCHAIN_FILE="$ANDROID_NDK/build/cmake/android.toolchain.cmake" \
+ -DANDROID_ABI="$ABI" \
+ -DANDROID_PLATFORM=android-26 \
+ -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON \
+ -DCMAKE_BUILD_TYPE=Release
+ # ^ 16 KB ELF alignment for the 64-bit ABIs (Google Play, Android 15+ devices): NDK r27 — the CI's — still
+ # defaults to 4 KB; r28+ default to 16 KB. 32-bit stays 4 KB. Same flag as the builder's app template.
+
+ cmake --build "$BUILD_DIR"
+
+ rm -rf "$BUILD_DIR"
+
+ echo " -> plugins/Notifications/libs/android/$ABI/release/libsokol_notifications.so"
+done
+
+echo ""
+echo "========================================="
+echo "sokol_notifications — Android done!"
+for ABI in "${ANDROID_ABIS[@]}"; do
+ echo " $ABI: plugins/Notifications/libs/android/$ABI/release/libsokol_notifications.so"
+done
+echo "========================================="
+echo ""
+echo "Next: rebuild the APK so libsokol_notifications.so is packaged alongside libsokol.so."
diff --git a/plugins/Notifications/scripts/build-ios.sh b/plugins/Notifications/scripts/build-ios.sh
new file mode 100755
index 00000000..502509db
--- /dev/null
+++ b/plugins/Notifications/scripts/build-ios.sh
@@ -0,0 +1,82 @@
+#!/bin/bash
+# Build sokol_notifications.framework for iOS — standalone Notifications plugin library.
+# Output: libs/ios//{debug,release}/sokol_notifications.framework
+# Run from any directory; the script resolves the repo root automatically.
+#
+# Usage: ./plugins/Notifications/scripts/build-ios.sh [device|simulator-arm64|simulator-x64|all]
+# Default: all
+#
+# The framework compiles an Objective-C bridge (UNUserNotificationCenter) plus the
+# shared C event queue. Deployment target 15.0. No link against the main sokol
+# framework is required.
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
+PLUGIN_NATIVE="$REPO_ROOT/plugins/Notifications/native"
+
+BUILD_TARGET="${1:-all}"
+
+build_for_target() {
+ local TARGET=$1
+ local ARCH=$2
+ local SDK=$3
+ local OUTPUT_DIR=$4
+
+ echo "========================================="
+ echo "sokol_notifications — iOS $TARGET ($ARCH)"
+ echo "========================================="
+
+ local BUILD_DIR="$REPO_ROOT/build-sokol-notifications-ios-$TARGET"
+ rm -rf "$BUILD_DIR"
+
+ cmake -S "$PLUGIN_NATIVE" -B "$BUILD_DIR" -G Xcode \
+ -DCMAKE_SYSTEM_NAME=iOS \
+ -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \
+ -DCMAKE_OSX_ARCHITECTURES="$ARCH" \
+ -DCMAKE_OSX_SYSROOT="$SDK"
+
+ echo "Building Release..."
+ cmake --build "$BUILD_DIR" --config Release
+
+ echo "Building Debug..."
+ cmake --build "$BUILD_DIR" --config Debug
+
+ rm -rf "$BUILD_DIR"
+
+ echo "Done — $TARGET"
+ echo " Release: plugins/Notifications/libs/ios/$OUTPUT_DIR/release/sokol_notifications.framework"
+ echo " Debug: plugins/Notifications/libs/ios/$OUTPUT_DIR/debug/sokol_notifications.framework"
+}
+
+case "$BUILD_TARGET" in
+ device)
+ build_for_target "device" "arm64" "iphoneos" "arm64"
+ ;;
+ simulator-arm64)
+ build_for_target "simulator-arm64" "arm64" "iphonesimulator" "simulator-arm64"
+ ;;
+ simulator-x64)
+ build_for_target "simulator-x64" "x86_64" "iphonesimulator" "simulator-x64"
+ ;;
+ all)
+ build_for_target "device" "arm64" "iphoneos" "arm64"
+ build_for_target "simulator-arm64" "arm64" "iphonesimulator" "simulator-arm64"
+ build_for_target "simulator-x64" "x86_64" "iphonesimulator" "simulator-x64"
+ echo "========================================="
+ echo "sokol_notifications — iOS all targets done!"
+ echo " Device: plugins/Notifications/libs/ios/arm64/{debug,release}/sokol_notifications.framework"
+ echo " Simulator (arm64): plugins/Notifications/libs/ios/simulator-arm64/{debug,release}/sokol_notifications.framework"
+ echo " Simulator (x86_64): plugins/Notifications/libs/ios/simulator-x64/{debug,release}/sokol_notifications.framework"
+ echo "========================================="
+ ;;
+ *)
+ echo "Error: unknown target '$BUILD_TARGET'"
+ echo "Usage: $0 [device|simulator-arm64|simulator-x64|all]"
+ exit 1
+ ;;
+esac
+
+echo ""
+echo "Next: embed sokol_notifications.framework in the app bundle alongside sokol.framework."
diff --git a/plugins/README.md b/plugins/README.md
index 1f29d128..ae357bbd 100644
--- a/plugins/README.md
+++ b/plugins/README.md
@@ -13,6 +13,7 @@ Optional, self-contained add-ons that extend Sokol.NET applications.
| [Links](Links/README.md) | Android, iOS, macOS, Windows, Linux, Web | Open a link with the system handler (`Links.Open(url)`): web URLs in the default browser, `mailto:` in the mail app |
| [IncomingLinks](IncomingLinks/README.md) | Android | Receive the link that opened the app (`IncomingLinks.TakePending()`): Android App Links and custom URL schemes, cold start and while running |
| [AppUpdate](AppUpdate/README.md) | Android | Google Play In-App Updates, FLEXIBLE flow (`AppUpdate.Check()` / `StartFlexible()` / `Complete()`), with Play's `FakeAppUpdateManager` for testing |
+| [Notifications](Notifications/README.md) | Android, iOS | Local scheduled notifications (`Notifications.Schedule(id, at, …)`): WorkManager / `UNUserNotificationCenter`, no push or server; permission asked when the app chooses |
`Ads` and `Billing` have no desktop or web backend. Rather than requiring `#if` at every call site,
their managed layer stubs each call and completes it through the normal event path (queries fail,
diff --git a/tools/SokolApplicationBuilder/Source/AndroidAppBuilder.cs b/tools/SokolApplicationBuilder/Source/AndroidAppBuilder.cs
index 481ae824..d7149b71 100644
--- a/tools/SokolApplicationBuilder/Source/AndroidAppBuilder.cs
+++ b/tools/SokolApplicationBuilder/Source/AndroidAppBuilder.cs
@@ -2010,12 +2010,24 @@ void ConfigureJavaRuntimePermissions(string androidPath, Dictionary(
+ androidProperties.GetValueOrDefault("AndroidPermissionsNoAutoRequest", "")
+ .Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries)
+ .Select(p => p.Trim()),
+ StringComparer.OrdinalIgnoreCase);
+
var declaredPermissions = GetAndroidPermissions(androidProperties);
var runtimePermissions = declaredPermissions
.Select(ParsePermission)
- .Where(p => DangerousPermissions.ContainsKey(p.Name))
+ .Where(p => DangerousPermissions.ContainsKey(p.Name) && !noAutoRequest.Contains(p.Name))
.ToList();
+ if (noAutoRequest.Count > 0)
+ Log.LogMessage(MessageImportance.High, $"ℹ️ Not requested at launch (AndroidPermissionsNoAutoRequest): {string.Join(", ", noAutoRequest)}");
+
if (runtimePermissions.Count == 0)
{
// No dangerous permissions – remove placeholders cleanly