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