From ec9bfd76cb88fa240639d5f7390526effbfaab28 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 1 Apr 2026 01:03:07 +1100 Subject: [PATCH 01/12] Add notification-related methods --- example/lib/home.dart | 162 +++++++++++++++++++++ lib/solidpod.dart | 11 +- lib/src/solid/constants/common.dart | 1 + lib/src/solid/models/pod_notification.dart | 101 +++++++++++++ lib/src/solid/send_notification.dart | 111 ++++++++++++++ lib/src/solid/utils/init_helper.dart | 14 +- 6 files changed, 397 insertions(+), 3 deletions(-) create mode 100644 lib/src/solid/models/pod_notification.dart create mode 100644 lib/src/solid/send_notification.dart diff --git a/example/lib/home.dart b/example/lib/home.dart index 75b4d05d..f723045d 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -226,6 +226,148 @@ class HomeState extends State with SingleTickerProviderStateMixin { } } + Future _showSendNotificationDialog() async { + final loggedIn = await loginIfRequired(context); + if (!loggedIn) return; + + final recipientController = TextEditingController(); + final titleController = TextEditingController(); + final contentController = TextEditingController(); + int selectedPriority = 0; + + await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (stfContext, setDialogState) { + return AlertDialog( + title: const Text('Send Notification'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: recipientController, + decoration: const InputDecoration( + labelText: 'Recipient WebID', + hintText: demoWebID, + ), + ), + const SizedBox(height: 12), + TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Title', + ), + ), + const SizedBox(height: 12), + TextField( + controller: contentController, + decoration: const InputDecoration( + labelText: 'Content (optional)', + ), + maxLines: 3, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: selectedPriority, + decoration: const InputDecoration( + labelText: 'Priority', + ), + items: const [ + DropdownMenuItem(value: 0, child: Text('Low')), + DropdownMenuItem(value: 1, child: Text('Medium')), + DropdownMenuItem(value: 2, child: Text('High')), + ], + onChanged: (value) { + setDialogState(() { + selectedPriority = value ?? 0; + }); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final recipient = recipientController.text.trim(); + final notifTitle = titleController.text.trim(); + + if (recipient.isEmpty || notifTitle.isEmpty) { + ScaffoldMessenger.of(stfContext).showSnackBar( + const SnackBar( + content: + Text('Recipient WebID and title are required.'), + ), + ); + return; + } + + Navigator.pop(dialogContext); + + try { + await sendNotification( + recipientWebId: recipient, + title: notifTitle, + content: contentController.text.trim().isEmpty + ? null + : contentController.text.trim(), + priority: selectedPriority, + ); + + if (context.mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Success'), + content: const Text( + 'Notification sent successfully.', + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ), + ], + ), + ); + } + } on Exception catch (e) { + debugPrint('Failed to send notification: $e'); + if (context.mounted) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Error'), + content: Text( + 'Failed to send notification:\n$e', + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('OK'), + ), + ], + ), + ); + } + } + }, + child: const Text('Send'), + ), + ], + ); + }, + ); + }, + ); + } + Widget _build(BuildContext context, String title) { // Build the widget. @@ -465,6 +607,26 @@ class HomeState extends State with SingleTickerProviderStateMixin { largeGapV, + const Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Notifications', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + smallGapV, + ElevatedButton( + onPressed: _showSendNotificationDialog, + child: const Text('Send Notification'), + ), + + largeGapV, + const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/solidpod.dart b/lib/solidpod.dart index 4b4c8834..0c80dc34 100644 --- a/lib/solidpod.dart +++ b/lib/solidpod.dart @@ -237,11 +237,20 @@ export 'src/solid/read_external_pod.dart'; export 'src/solid/write_external_pod.dart'; +/// Send notifications to another user's POD + +export 'src/solid/send_notification.dart'; + +/// Data model for POD notifications + +export 'src/solid/models/pod_notification.dart' show PodNotification; + /// 20250917 gjw Extras that were required for notepod. Not yet documented. /// 20251103 jesscmoore In common.dart, only authUserPred is /// used by notepod -export 'src/solid/constants/common.dart' show dataDir, profCard, authUserPred; +export 'src/solid/constants/common.dart' + show dataDir, profCard, authUserPred, notificationDir; /// Function to get resources in a user's POD diff --git a/lib/src/solid/constants/common.dart b/lib/src/solid/constants/common.dart index 27c5d775..216badee 100644 --- a/lib/src/solid/constants/common.dart +++ b/lib/src/solid/constants/common.dart @@ -56,6 +56,7 @@ const sharingDir = 'sharing'; const sharedDir = 'shared'; const encDir = 'encryption'; const logsDir = 'logs'; +const notificationDir = 'notification'; /// String terms used as predicates in ttl files. diff --git a/lib/src/solid/models/pod_notification.dart b/lib/src/solid/models/pod_notification.dart new file mode 100644 index 00000000..35f6955e --- /dev/null +++ b/lib/src/solid/models/pod_notification.dart @@ -0,0 +1,101 @@ +/// Data model for POD notifications. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +/// A notification to be stored in a recipient's POD. +/// +/// Each notification is serialised as a JSON file in the recipient's +/// `appDirName/notification/` folder. The file is named after its +/// [timestamp] (Unix epoch milliseconds) for chronological sorting. + +class PodNotification { + /// WebID of the user who sent the notification. + + final String senderWebId; + + /// WebID of the intended recipient. + + final String recipientWebId; + + /// Short summary of the notification. + + final String title; + + /// Optional detailed body text. + + final String? content; + + /// Priority level (0 = low, 1 = medium, 2 = high). + + final int priority; + + /// Unix timestamp in milliseconds when the notification was created. + + final int timestamp; + + const PodNotification({ + required this.senderWebId, + required this.recipientWebId, + required this.title, + this.content, + required this.priority, + required this.timestamp, + }); + + /// Serialise to a JSON-compatible map. + + Map toJson() => { + 'senderWebId': senderWebId, + 'recipientWebId': recipientWebId, + 'title': title, + if (content != null) 'content': content, + 'priority': priority, + 'timestamp': timestamp, + }; + + /// Deserialise from a JSON-compatible map. + + factory PodNotification.fromJson(Map json) => + PodNotification( + senderWebId: json['senderWebId'] as String, + recipientWebId: json['recipientWebId'] as String, + title: json['title'] as String, + content: json['content'] as String?, + priority: json['priority'] as int, + timestamp: json['timestamp'] as int, + ); + + @override + String toString() => 'PodNotification(' + 'sender: $senderWebId, ' + 'recipient: $recipientWebId, ' + 'title: $title, ' + 'priority: $priority, ' + 'timestamp: $timestamp)'; +} diff --git a/lib/src/solid/send_notification.dart b/lib/src/solid/send_notification.dart new file mode 100644 index 00000000..8d349b22 --- /dev/null +++ b/lib/src/solid/send_notification.dart @@ -0,0 +1,111 @@ +/// Function to send a notification to a recipient's POD. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:solidpod/src/solid/constants/common.dart'; +import 'package:solidpod/src/solid/constants/path_type.dart'; +import 'package:solidpod/src/solid/models/pod_notification.dart'; +import 'package:solidpod/src/solid/utils/authdata_manager.dart'; +import 'package:solidpod/src/solid/utils/exceptions.dart'; +import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; +import 'package:solidpod/src/solid/write_pod.dart'; + +/// Send a notification to a specified recipient's POD. +/// +/// The notification is written as an unencrypted JSON file to the recipient's +/// notification folder (`appDirName/notification/`). The file is named using +/// the current Unix timestamp in milliseconds for chronological sorting. +/// +/// Because the sender does not possess the recipient's security key, the +/// notification is stored without encryption via [writePod] with +/// `encrypted: false`. +/// +/// Arguments: +/// - [recipientWebId]: The full WebID of the notification recipient +/// (e.g. `https://pods.solidcommunity.au/john-doe/profile/card#me`) +/// - [title]: The notification title +/// - [content]: Optional notification body text +/// - [priority]: Notification priority level (default: 0). +/// Convention: 0 = low, 1 = medium, 2 = high +/// +/// Throws [NotLoggedInException] if the user is not authenticated. + +Future sendNotification({ + required String recipientWebId, + required String title, + String? content, + int priority = 0, +}) async { + if (!await isUserLoggedIn()) { + throw NotLoggedInException( + 'User must be logged in to send notifications', + ); + } + + final senderWebId = await AuthDataManager.getWebId(); + if (senderWebId == null || senderWebId.isEmpty) { + throw NotLoggedInException('Unable to retrieve sender WebID'); + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + + final notification = PodNotification( + senderWebId: senderWebId, + recipientWebId: recipientWebId, + title: title, + content: content, + priority: priority, + timestamp: timestamp, + ); + + // Build the absolute file URL in the recipient's notification folder. + // WebID format: https://host/pod-name/profile/card#me + // Target: https://host/pod-name/appDirName/notification/.json + + final notificationPath = '$appDirName/$notificationDir/$timestamp.json'; + final fileUrl = recipientWebId.replaceAll(profCard, notificationPath); + + final jsonContent = jsonEncode(notification.toJson()); + + // Write the notification JSON to the recipient's POD unencrypted. + // createAcl is false because the folder-level ACL already grants + // write access to authenticated users. + + await writePod( + fileUrl, + jsonContent, + encrypted: false, + createAcl: false, + pathType: PathType.absoluteUrl, + ); +} diff --git a/lib/src/solid/utils/init_helper.dart b/lib/src/solid/utils/init_helper.dart index 4e03b605..c29cf206 100644 --- a/lib/src/solid/utils/init_helper.dart +++ b/lib/src/solid/utils/init_helper.dart @@ -115,6 +115,7 @@ Future> generateDefaultFolders() async { final sharedDirLoc = [appDirName, sharedDir].join('/'); final encDirLoc = [appDirName, encDir].join('/'); final logDirLoc = [appDirName, logsDir].join('/'); + final notificationDirLoc = [appDirName, notificationDir].join('/'); final folders = [ appDirName, @@ -123,6 +124,7 @@ Future> generateDefaultFolders() async { dataDirLoc, encDirLoc, logDirLoc, + notificationDirLoc, ]; return folders; } @@ -159,12 +161,14 @@ Future> generateDefaultFiles() async { final sharedDirLoc = [appDirName, sharedDir].join('/'); final encDirLoc = [appDirName, encDir].join('/'); final logDirLoc = [appDirName, logsDir].join('/'); + final notificationDirLoc = [appDirName, notificationDir].join('/'); final files = { sharingDirLoc: [pubKeyFile, '$pubKeyFile.acl'], logDirLoc: [permLogFile, '$permLogFile.acl'], sharedDirLoc: ['.acl'], encDirLoc: [encKeyFile, indKeyFile], + notificationDirLoc: ['.acl'], }; return files; } @@ -236,7 +240,8 @@ Future initPod( if (f.split('.').last == 'acl') { final items = f.split('.'); final resourceUrl = items.getRange(0, items.length - 1).join('.'); - late Set publicAccess; + Set? publicAccess; + Set? authUserAccess; var isFile = true; switch (fileName) { case '$pubKeyFile.acl': @@ -245,14 +250,19 @@ Future initPod( publicAccess = {AccessMode.append}; default: assert(fileName == '.acl'); - publicAccess = {AccessMode.read, AccessMode.write}; isFile = false; + if (f.contains('/$notificationDir/')) { + authUserAccess = {AccessMode.read, AccessMode.write}; + } else { + publicAccess = {AccessMode.read, AccessMode.write}; + } } fileContent = await genAclTurtle( resourceUrl, isFile: isFile, publicAccess: publicAccess, + authUserAccess: authUserAccess, ); aclFlag = true; From d5a7033d3b02edb1ce9f61253f11eb646ba8826a Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 00:10:48 +1100 Subject: [PATCH 02/12] Refine the code --- example/lib/home.dart | 38 ++++++++++++++++------------ lib/solidpod.dart | 2 +- lib/src/solid/send_notification.dart | 2 -- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/example/lib/home.dart b/example/lib/home.dart index f723045d..b616d5b2 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -233,7 +233,9 @@ class HomeState extends State with SingleTickerProviderStateMixin { final recipientController = TextEditingController(); final titleController = TextEditingController(); final contentController = TextEditingController(); - int selectedPriority = 0; + int selectedPriority = 1; + String? recipientError; + String? titleError; await showDialog( context: context, @@ -248,16 +250,17 @@ class HomeState extends State with SingleTickerProviderStateMixin { children: [ TextField( controller: recipientController, - decoration: const InputDecoration( - labelText: 'Recipient WebID', - hintText: demoWebID, + decoration: InputDecoration( + labelText: 'Recipient WebID *', + errorText: recipientError, ), ), const SizedBox(height: 12), TextField( controller: titleController, - decoration: const InputDecoration( - labelText: 'Title', + decoration: InputDecoration( + labelText: 'Title *', + errorText: titleError, ), ), const SizedBox(height: 12), @@ -281,7 +284,7 @@ class HomeState extends State with SingleTickerProviderStateMixin { ], onChanged: (value) { setDialogState(() { - selectedPriority = value ?? 0; + selectedPriority = value ?? 1; }); }, ), @@ -298,15 +301,18 @@ class HomeState extends State with SingleTickerProviderStateMixin { final recipient = recipientController.text.trim(); final notifTitle = titleController.text.trim(); - if (recipient.isEmpty || notifTitle.isEmpty) { - ScaffoldMessenger.of(stfContext).showSnackBar( - const SnackBar( - content: - Text('Recipient WebID and title are required.'), - ), - ); - return; - } + final hasErrors = + recipient.isEmpty || notifTitle.isEmpty; + + setDialogState(() { + recipientError = recipient.isEmpty + ? 'Recipient WebID is required' + : null; + titleError = + notifTitle.isEmpty ? 'Title is required' : null; + }); + + if (hasErrors) return; Navigator.pop(dialogContext); diff --git a/lib/solidpod.dart b/lib/solidpod.dart index 0c80dc34..49bf02ae 100644 --- a/lib/solidpod.dart +++ b/lib/solidpod.dart @@ -250,7 +250,7 @@ export 'src/solid/models/pod_notification.dart' show PodNotification; /// used by notepod export 'src/solid/constants/common.dart' - show dataDir, profCard, authUserPred, notificationDir; + show appDirName, dataDir, profCard, authUserPred, notificationDir; /// Function to get resources in a user's POD diff --git a/lib/src/solid/send_notification.dart b/lib/src/solid/send_notification.dart index 8d349b22..2ce1b6c0 100644 --- a/lib/src/solid/send_notification.dart +++ b/lib/src/solid/send_notification.dart @@ -30,8 +30,6 @@ library; import 'dart:convert'; -import 'package:flutter/foundation.dart' show debugPrint; - import 'package:solidpod/src/solid/constants/common.dart'; import 'package:solidpod/src/solid/constants/path_type.dart'; import 'package:solidpod/src/solid/models/pod_notification.dart'; From 6112ec0bd1246daceb6e569c89bb49f79c68d358 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 00:37:15 +1100 Subject: [PATCH 03/12] Lint --- example/lib/home.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/example/lib/home.dart b/example/lib/home.dart index b616d5b2..45f440bd 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -273,7 +273,7 @@ class HomeState extends State with SingleTickerProviderStateMixin { ), const SizedBox(height: 12), DropdownButtonFormField( - value: selectedPriority, + initialValue: selectedPriority, decoration: const InputDecoration( labelText: 'Priority', ), @@ -301,8 +301,7 @@ class HomeState extends State with SingleTickerProviderStateMixin { final recipient = recipientController.text.trim(); final notifTitle = titleController.text.trim(); - final hasErrors = - recipient.isEmpty || notifTitle.isEmpty; + final hasErrors = recipient.isEmpty || notifTitle.isEmpty; setDialogState(() { recipientError = recipient.isEmpty From 67aa0d2c586d61c2e25f838989b8cd4122155e59 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 01:36:11 +1100 Subject: [PATCH 04/12] Update pubspec.yaml --- example/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index fafcd772..30f7ca65 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -25,7 +25,7 @@ dependency_overrides: solidui: git: url: https://github.com/anusii/solidui.git - ref: dev + ref: tony/257_notification dev_dependencies: dependency_validator: ^5.0.4 From 78540153a31eb950401b4a4dd117895dd5b93088 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 2 Apr 2026 23:00:45 +1100 Subject: [PATCH 05/12] Fix the POD initialisation issue --- lib/src/solid/send_notification.dart | 36 ++++++++++++++++++---------- lib/src/solid/utils/init_helper.dart | 29 +++++++++++++++------- 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/lib/src/solid/send_notification.dart b/lib/src/solid/send_notification.dart index 2ce1b6c0..78bd3062 100644 --- a/lib/src/solid/send_notification.dart +++ b/lib/src/solid/send_notification.dart @@ -30,13 +30,14 @@ library; import 'dart:convert'; +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:solidpod/src/solid/api/rest_api.dart'; import 'package:solidpod/src/solid/constants/common.dart'; -import 'package:solidpod/src/solid/constants/path_type.dart'; import 'package:solidpod/src/solid/models/pod_notification.dart'; import 'package:solidpod/src/solid/utils/authdata_manager.dart'; import 'package:solidpod/src/solid/utils/exceptions.dart'; import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; -import 'package:solidpod/src/solid/write_pod.dart'; /// Send a notification to a specified recipient's POD. /// @@ -44,9 +45,16 @@ import 'package:solidpod/src/solid/write_pod.dart'; /// notification folder (`appDirName/notification/`). The file is named using /// the current Unix timestamp in milliseconds for chronological sorting. /// -/// Because the sender does not possess the recipient's security key, the -/// notification is stored without encryption via [writePod] with -/// `encrypted: false`. +/// This function writes directly via [createResource] using an HTTP POST +/// (not PUT) to the notification container. POST is used because the +/// notification directory's ACL grants public **Append** access, and the +/// Solid Protocol requires only `acl:Append` for POST requests to a +/// container, whereas PUT requires `acl:Write`. A `Slug` header suggests +/// the desired file name. +/// +/// [writePod] is intentionally avoided because its pre-flight GET request +/// may return 403 on another user's POD, aborting before the write is ever +/// attempted. /// /// Arguments: /// - [recipientWebId]: The full WebID of the notification recipient @@ -95,15 +103,17 @@ Future sendNotification({ final jsonContent = jsonEncode(notification.toJson()); - // Write the notification JSON to the recipient's POD unencrypted. - // createAcl is false because the folder-level ACL already grants - // write access to authenticated users. + // debugPrint('[sendNotification] Writing to: $fileUrl'); + + // POST the notification JSON to the recipient's notification container. + // replaceIfExist: false triggers POST (instead of PUT), which only + // requires Append access on the container — matching the public Append + // ACL configured during POD initialisation. - await writePod( + await createResource( fileUrl, - jsonContent, - encrypted: false, - createAcl: false, - pathType: PathType.absoluteUrl, + content: jsonContent, + contentType: ResourceContentType.auto, + replaceIfExist: false, ); } diff --git a/lib/src/solid/utils/init_helper.dart b/lib/src/solid/utils/init_helper.dart index c29cf206..d5246919 100644 --- a/lib/src/solid/utils/init_helper.dart +++ b/lib/src/solid/utils/init_helper.dart @@ -193,12 +193,16 @@ Future initPod( dirUrls = [for (final d in defaultDirs) await getDirUrl(d)]; } - // Require the creation of the encryption directory and - // the encKeyFile and indKeyFile in it. + // Determine whether this is a full first-time initialisation or a partial + // re-init (e.g. only the notification directory is missing). The encryption + // directory is only in dirUrls when it does not yet exist on the server. final encDirUrl = await getDirUrl(await getEncDirPath()); - if (!dirUrls.contains(encDirUrl)) { - throw Exception('Can not initialise POD without creating $encDirUrl'); + final isFullInit = dirUrls.contains(encDirUrl); + + if (isFullInit) { + // First-time setup — the encryption directory must be present. + assert(dirUrls.contains(encDirUrl)); } // Create the required directories. @@ -224,10 +228,13 @@ Future initPod( } } - // Create the encKeyFile, indKeyFile and pubKeyFile - // and remove them from the fileUrls list. + // Initialise encryption keys only during first-time setup. During a partial + // re-init the keys already exist on the server and must not be overwritten, + // as doing so would invalidate all previously encrypted data. - await KeyManager.initPodKeys(securityKey); + if (isFullInit) { + await KeyManager.initPodKeys(securityKey); + } fileUrls.remove(await getFileUrl(await getEncKeyPath())); fileUrls.remove(await getFileUrl(await getIndKeyPath())); fileUrls.remove(await getFileUrl(await getPubKeyPath())); @@ -252,7 +259,13 @@ Future initPod( assert(fileName == '.acl'); isFile = false; if (f.contains('/$notificationDir/')) { - authUserAccess = {AccessMode.read, AccessMode.write}; + // Grant public Append so that any user (including cross-pod + // senders) can POST new notification files. Read/Write/Control + // remain with the owner only. Using foaf:Agent rather than + // acl:AuthenticatedAgent because CSS does not reliably honour + // AuthenticatedAgent for cross-pod writes with DPoP tokens. + + publicAccess = {AccessMode.append}; } else { publicAccess = {AccessMode.read, AccessMode.write}; } From 0991c30ecc31c13d2722c193392142ab76f08781 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Fri, 3 Apr 2026 00:32:19 +1100 Subject: [PATCH 06/12] Lint --- lib/src/solid/send_notification.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/src/solid/send_notification.dart b/lib/src/solid/send_notification.dart index 78bd3062..2ff21e55 100644 --- a/lib/src/solid/send_notification.dart +++ b/lib/src/solid/send_notification.dart @@ -30,8 +30,6 @@ library; import 'dart:convert'; -import 'package:flutter/foundation.dart' show debugPrint; - import 'package:solidpod/src/solid/api/rest_api.dart'; import 'package:solidpod/src/solid/constants/common.dart'; import 'package:solidpod/src/solid/models/pod_notification.dart'; From acbd71133f28756809e74062131678d667a5debb Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 14 Apr 2026 02:19:35 +1000 Subject: [PATCH 07/12] Optimise the error message --- lib/solidpod.dart | 1 + lib/src/solid/send_notification.dart | 64 ++++++++++++++++++++++++---- lib/src/solid/utils/exceptions.dart | 13 ++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/lib/solidpod.dart b/lib/solidpod.dart index 49bf02ae..0e31546d 100644 --- a/lib/solidpod.dart +++ b/lib/solidpod.dart @@ -84,6 +84,7 @@ export 'src/solid/utils/exceptions.dart' AccessForbiddenException, AccessFailedException, NotLoggedInException, + RecipientNotReadyException, ResourceNotExistException, SecurityKeyNotAvailableException; diff --git a/lib/src/solid/send_notification.dart b/lib/src/solid/send_notification.dart index 2ff21e55..02bb6273 100644 --- a/lib/src/solid/send_notification.dart +++ b/lib/src/solid/send_notification.dart @@ -63,6 +63,8 @@ import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; /// Convention: 0 = low, 1 = medium, 2 = high /// /// Throws [NotLoggedInException] if the user is not authenticated. +/// Throws [RecipientNotReadyException] if the recipient's WebID does not +/// exist or their Pod lacks the notification folder for this app. Future sendNotification({ required String recipientWebId, @@ -81,6 +83,36 @@ Future sendNotification({ throw NotLoggedInException('Unable to retrieve sender WebID'); } + // Pre-flight checks. + + // 1. Verify the recipient's WebID exists (unauthenticated GET to the + // public profile document). + + final webIdStatus = await checkWebIdExists(recipientWebId); + if (webIdStatus == ResourceStatus.notExist) { + throw RecipientNotReadyException( + 'The recipient WebID does not exist: $recipientWebId. ' + 'Please check the WebID is correct.', + ); + } + + // 2. Verify the recipient has initialised their Pod for this app. + // checkPodInitialised performs an authenticated GET on the recipient's + // shared directory — the same check used in the grant-permissions + // workflow. If it returns false the recipient has never set up the app. + + final podReady = await checkPodInitialised(recipientWebId); + if (!podReady) { + throw RecipientNotReadyException( + 'The recipient ($recipientWebId) has not set up this app on their ' + 'Pod. They need to log in to the app first so that the required ' + 'folder structure is created, before you can send notifications ' + 'to them.', + ); + } + + // Build and send the notification. + final timestamp = DateTime.now().millisecondsSinceEpoch; final notification = PodNotification( @@ -101,17 +133,33 @@ Future sendNotification({ final jsonContent = jsonEncode(notification.toJson()); - // debugPrint('[sendNotification] Writing to: $fileUrl'); - // POST the notification JSON to the recipient's notification container. // replaceIfExist: false triggers POST (instead of PUT), which only // requires Append access on the container — matching the public Append // ACL configured during POD initialisation. + // + // If the POST still fails (e.g. the notification folder was added in a + // newer app version that the recipient has not yet run), convert the + // error into a RecipientNotReadyException with actionable guidance. - await createResource( - fileUrl, - content: jsonContent, - contentType: ResourceContentType.auto, - replaceIfExist: false, - ); + try { + await createResource( + fileUrl, + content: jsonContent, + contentType: ResourceContentType.auto, + replaceIfExist: false, + ); + } on Exception catch (e) { + final errStr = e.toString(); + if (errStr.contains('403') || errStr.contains('Forbidden')) { + throw RecipientNotReadyException( + 'The recipient ($recipientWebId) does not have a notification ' + 'folder for this app. This typically happens when the recipient ' + 'has not run the latest version of the app. They need to log in ' + 'and update their app setup in their Pod before you can send ' + 'notifications to them.', + ); + } + rethrow; + } } diff --git a/lib/src/solid/utils/exceptions.dart b/lib/src/solid/utils/exceptions.dart index 93b7180f..5d5ce09e 100644 --- a/lib/src/solid/utils/exceptions.dart +++ b/lib/src/solid/utils/exceptions.dart @@ -72,3 +72,16 @@ class SecurityKeyNotAvailableException implements Exception { @override String toString() => 'SecurityKeyNotAvailableException: $message'; } + +/// Thrown when a notification cannot be delivered because the recipient's Pod +/// is not ready — either their WebID does not exist, they have not set up the +/// app, or their notification folder has not yet been created. + +class RecipientNotReadyException implements Exception { + final String message; + + RecipientNotReadyException(this.message); + + @override + String toString() => 'RecipientNotReadyException: $message'; +} From 6c953da89b96b905971950e86b089fbc262f0d60 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Tue, 14 Apr 2026 15:00:04 +1000 Subject: [PATCH 08/12] include app name in the notification error message --- lib/src/solid/send_notification.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/src/solid/send_notification.dart b/lib/src/solid/send_notification.dart index 02bb6273..0c077658 100644 --- a/lib/src/solid/send_notification.dart +++ b/lib/src/solid/send_notification.dart @@ -68,6 +68,7 @@ import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; Future sendNotification({ required String recipientWebId, + String appName = 'this app', required String title, String? content, int priority = 0, @@ -104,10 +105,9 @@ Future sendNotification({ final podReady = await checkPodInitialised(recipientWebId); if (!podReady) { throw RecipientNotReadyException( - 'The recipient ($recipientWebId) has not set up this app on their ' - 'Pod. They need to log in to the app first so that the required ' - 'folder structure is created, before you can send notifications ' - 'to them.', + '$recipientWebId has not logged in to $appName recently. Ask them to login to $appName, ' + 'which will create the notification ' + 'folder. Then you can send them notifications.', ); } @@ -154,9 +154,9 @@ Future sendNotification({ if (errStr.contains('403') || errStr.contains('Forbidden')) { throw RecipientNotReadyException( 'The recipient ($recipientWebId) does not have a notification ' - 'folder for this app. This typically happens when the recipient ' - 'has not run the latest version of the app. They need to log in ' - 'and update their app setup in their Pod before you can send ' + 'folder for $appName. This typically happens when the recipient ' + 'has not run the latest version of $appName. They need to log in ' + 'to setup their Pod before you can send ' 'notifications to them.', ); } From bfddde931d5e8f4f60b7c51b1169433eda161c50 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Tue, 14 Apr 2026 15:00:36 +1000 Subject: [PATCH 09/12] store app name as a contant in example app --- example/lib/constants/app.dart | 5 +++++ example/lib/dialogs/about.dart | 5 +++-- example/lib/home.dart | 1 + example/lib/main.dart | 7 ++++--- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/example/lib/constants/app.dart b/example/lib/constants/app.dart index 4e293162..3575f4d8 100644 --- a/example/lib/constants/app.dart +++ b/example/lib/constants/app.dart @@ -29,6 +29,11 @@ import 'package:flutter/material.dart'; const titleBackgroundColor = Color(0xFFF0E4D7); +class AppConstants { + static const shortName = 'Demopod'; + static const longName = 'Solid Pod Demonstrator'; +} + // const dataFile = 'key-value.ttl'; const dataFile = 'keyvalue/key-value.ttl'; diff --git a/example/lib/dialogs/about.dart b/example/lib/dialogs/about.dart index dd383f03..ede7c96d 100644 --- a/example/lib/dialogs/about.dart +++ b/example/lib/dialogs/about.dart @@ -29,6 +29,8 @@ import 'package:flutter/material.dart'; import 'package:solidpod/solidpod.dart'; +import 'package:demopod/constants/app.dart'; + Future aboutDialog(BuildContext context) async { final appInfo = await getAppNameVersion(); @@ -37,8 +39,7 @@ Future aboutDialog(BuildContext context) async { if (context.mounted) { showAboutDialog( context: context, - applicationName: - '${appInfo.name[0].toUpperCase()}${appInfo.name.substring(1)}', + applicationName: AppConstants.shortName, applicationVersion: appInfo.version, applicationLegalese: '© 2024 Software Innovation Institute ANU', applicationIcon: Image.asset( diff --git a/example/lib/home.dart b/example/lib/home.dart index 45f440bd..492dc087 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -318,6 +318,7 @@ class HomeState extends State with SingleTickerProviderStateMixin { try { await sendNotification( recipientWebId: recipient, + appName: AppConstants.shortName, title: notifTitle, content: contentController.text.trim().isEmpty ? null diff --git a/example/lib/main.dart b/example/lib/main.dart index 9346ef00..5997adf2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -30,6 +30,7 @@ import 'package:flutter/material.dart'; import 'package:solidui/solidui.dart' show SolidLogin, InfoButtonStyle; import 'package:window_manager/window_manager.dart'; +import 'package:demopod/constants/app.dart'; import 'package:demopod/home.dart'; import 'package:demopod/utils/is_desktop.dart'; @@ -78,13 +79,13 @@ class DemoPod extends StatelessWidget { @override Widget build(BuildContext context) { - return const MaterialApp( - title: 'Solid Pod Demonstrator', + return MaterialApp( + title: AppConstants.longName, home: SolidLogin( // Images generated using Bing Image Creator from Designer, powered by // DALL-E3. - title: 'SOLID POD DEMONSTRATOR', + title: AppConstants.longName.toUpperCase(), appDirectory: 'exampleApp', image: AssetImage('assets/images/demopod_image.png'), logo: AssetImage('assets/images/demopod_logo.png'), From d5a87ca9b773f0faf1f5e2219f4e07949704bc5c Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Tue, 14 Apr 2026 15:01:38 +1000 Subject: [PATCH 10/12] store ios/Podfile in repo --- example/.gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example/.gitignore b/example/.gitignore index 44248147..d3f6276d 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -7,6 +7,8 @@ ignore # Platform-specific build folders /android/ /ios/ +# Except Podfile +!/ios/Podfile /linux/ /macos/ /windows/ From 1f0753dbe94d0c0ab21a7b61b5a61783b64f23e2 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Tue, 14 Apr 2026 15:19:13 +1000 Subject: [PATCH 11/12] use getAppName function --- lib/src/solid/send_notification.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/solid/send_notification.dart b/lib/src/solid/send_notification.dart index 0c077658..25b5006c 100644 --- a/lib/src/solid/send_notification.dart +++ b/lib/src/solid/send_notification.dart @@ -35,7 +35,8 @@ import 'package:solidpod/src/solid/constants/common.dart'; import 'package:solidpod/src/solid/models/pod_notification.dart'; import 'package:solidpod/src/solid/utils/authdata_manager.dart'; import 'package:solidpod/src/solid/utils/exceptions.dart'; -import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; +import 'package:solidpod/src/solid/utils/misc.dart' + show getAppNameVersion, isUserLoggedIn; /// Send a notification to a specified recipient's POD. /// @@ -68,11 +69,12 @@ import 'package:solidpod/src/solid/utils/misc.dart' show isUserLoggedIn; Future sendNotification({ required String recipientWebId, - String appName = 'this app', required String title, String? content, int priority = 0, }) async { + final appName = (await getAppNameVersion()).name; + if (!await isUserLoggedIn()) { throw NotLoggedInException( 'User must be logged in to send notifications', From 709abfbce2dc58269c8499443a798e74700e6731 Mon Sep 17 00:00:00 2001 From: Jess Moore Date: Tue, 14 Apr 2026 15:19:34 +1000 Subject: [PATCH 12/12] remove appName parameter --- example/lib/home.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/example/lib/home.dart b/example/lib/home.dart index 492dc087..45f440bd 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -318,7 +318,6 @@ class HomeState extends State with SingleTickerProviderStateMixin { try { await sendNotification( recipientWebId: recipient, - appName: AppConstants.shortName, title: notifTitle, content: contentController.text.trim().isEmpty ? null