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/ 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 75b4d05d..45f440bd 100644 --- a/example/lib/home.dart +++ b/example/lib/home.dart @@ -226,6 +226,153 @@ 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 = 1; + String? recipientError; + String? titleError; + + 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: InputDecoration( + labelText: 'Recipient WebID *', + errorText: recipientError, + ), + ), + const SizedBox(height: 12), + TextField( + controller: titleController, + decoration: InputDecoration( + labelText: 'Title *', + errorText: titleError, + ), + ), + const SizedBox(height: 12), + TextField( + controller: contentController, + decoration: const InputDecoration( + labelText: 'Content (optional)', + ), + maxLines: 3, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: 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 ?? 1; + }); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final recipient = recipientController.text.trim(); + final notifTitle = titleController.text.trim(); + + 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); + + 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 +612,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/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'), 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 diff --git a/lib/solidpod.dart b/lib/solidpod.dart index 4b4c8834..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; @@ -237,11 +238,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 appDirName, 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..25b5006c --- /dev/null +++ b/lib/src/solid/send_notification.dart @@ -0,0 +1,167 @@ +/// 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:solidpod/src/solid/api/rest_api.dart'; +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 getAppNameVersion, isUserLoggedIn; + +/// 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. +/// +/// 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 +/// (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. +/// 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, + 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', + ); + } + + final senderWebId = await AuthDataManager.getWebId(); + if (senderWebId == null || senderWebId.isEmpty) { + 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( + '$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.', + ); + } + + // Build and send the notification. + + 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()); + + // 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. + + 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 $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.', + ); + } + 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'; +} diff --git a/lib/src/solid/utils/init_helper.dart b/lib/src/solid/utils/init_helper.dart index 4e03b605..d5246919 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; } @@ -189,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. @@ -220,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())); @@ -236,7 +247,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 +257,25 @@ Future initPod( publicAccess = {AccessMode.append}; default: assert(fileName == '.acl'); - publicAccess = {AccessMode.read, AccessMode.write}; isFile = false; + if (f.contains('/$notificationDir/')) { + // 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}; + } } fileContent = await genAclTurtle( resourceUrl, isFile: isFile, publicAccess: publicAccess, + authUserAccess: authUserAccess, ); aclFlag = true;