diff --git a/CHANGELOG.md b/CHANGELOG.md index ac2517bf..5808f552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Visit the package at [pub.dev](https://pub.dev/packages/solidpod). ## 1.0 ++ Update dependency (including rdflib 0.2.13) [1.0.19] + Support both user.server and server/user URIs [1.0.18 20260902 dc] + Streamline login logout [1.0.17 20260828 anushkavidanage] + Session store on web for secrets [1.0.16 20260819 tonypioneer] diff --git a/example/.gitignore b/example/.gitignore index d3f6276d..79f7ecab 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -1,19 +1,3 @@ -generated* -Generated* -pubspec.lock -*~ -ignore - -# Platform-specific build folders -/android/ -/ios/ -# Except Podfile -!/ios/Podfile -/linux/ -/macos/ -/windows/ -/web/ - # Miscellaneous *.class *.log @@ -43,11 +27,11 @@ migrate_working_dir/ **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ -.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ /build/ +/coverage/ # Symbolication related app.*.symbols @@ -59,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/example/.metadata b/example/.metadata deleted file mode 100644 index 9b9642c2..00000000 --- a/example/.metadata +++ /dev/null @@ -1,30 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "ff37bef603469fb030f2b72995ab929ccfc227f0" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - platform: windows - create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/Makefile b/example/Makefile deleted file mode 100644 index 61e82401..00000000 --- a/example/Makefile +++ /dev/null @@ -1,83 +0,0 @@ -######################################################################## -# -# Generic Makefile -# -# Time-stamp: -# -# Copyright (c) Graham.Williams@togaware.com -# -# License: Creative Commons Attribution-ShareAlike 4.0 International. -# -######################################################################## - -# App is often the current directory name. -# -# App version numbers -# Major release -# Minor update -# Trivial update or bug fix - -APP=$(shell pwd | xargs basename) -VER= -DATE=$(shell date +%Y-%m-%d) - -# Identify a destination used by install.mk - -DEST=/var/www/html/$(APP) - -######################################################################## -# Supported Makefile modules. - -# Often the support Makefiles will be in the local support folder, or -# else installed in the local user's shares. - -INC_BASE=../support - -# Specific Makefiles will be loaded if they are found in -# INC_BASE. Sometimes the INC_BASE is shared by multiple local -# Makefiles and we want to skip specific makes. Simply define the -# appropriate INC to a non-existant location and it will be skipped. - -INC_DOCKER=skip -INC_MLHUB=skip -INC_WEBCAM=skip - -# Load any modules available. - -INC_MODULE=$(INC_BASE)/modules.mk - -ifneq ("$(wildcard $(INC_MODULE))","") - include $(INC_MODULE) -endif - -######################################################################## -# HELP -# -# Help for targets defined in this Makefile. - -define HELP -$(APP): - - locals No local targets defined yet. - -endef -export HELP - -help:: - @echo "$$HELP" - -######################################################################## -# LOCAL TARGETS - -locals: - @echo "This might be the instructions to install $(APP)" - -apk:: - rsync -avzh installers/$(APP)* solidcommunity.au:/var/www/html/installers/ - ssh solidcommunity.au chmod -R go+rX /var/www/html/installers/ - ssh solidcommunity.au chmod go=x /var/www/html/installers/ - -tgz:: - rsync -avzh installers/$(APP)* solidcommunity.au:/var/www/html/installers/ - ssh solidcommunity.au chmod -R go+rX /var/www/html/installers/ - ssh solidcommunity.au chmod go=x /var/www/html/installers/ diff --git a/example/README.md b/example/README.md index e829587a..66b19543 100644 --- a/example/README.md +++ b/example/README.md @@ -1,7 +1 @@ -# A Demonstrator of SolidPod Functionality - -Through this app we demonstrate the suite of functionality provided by -the solidpod package. - -Visit the [AU Solid Community](https://solidcommunity.au) for a suite -of solidpod apps developed using the solidpod package. +# Example of solidpod diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index bf8d4218..cedcc10f 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -7,6 +7,8 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + analyzer: exclude: - build/** @@ -16,7 +18,6 @@ analyzer: - windows/** - macos/** - linux/** -include: package:flutter_lints/flutter.yaml linter: # The lint rules applied to this project can be customized in the diff --git a/example/assets/images/demopod_image.png b/example/assets/images/demopod_image.png deleted file mode 100644 index 63a3f7ee..00000000 Binary files a/example/assets/images/demopod_image.png and /dev/null differ diff --git a/example/assets/images/demopod_logo.png b/example/assets/images/demopod_logo.png deleted file mode 100644 index 1509e627..00000000 Binary files a/example/assets/images/demopod_logo.png and /dev/null differ diff --git a/example/client-profile.jsonld b/example/client-profile.jsonld deleted file mode 100644 index e56cc404..00000000 --- a/example/client-profile.jsonld +++ /dev/null @@ -1,25 +0,0 @@ -{ - "@context": "https://www.w3.org/ns/solid/oidc-context.jsonld", - "client_id": "https://anusii.github.io/solidpodeg/client-profile.jsonld", - "client_name": "Solid Pod Demonstrator", - "application_type": "native", - "redirect_uris": [ - "https://anusii.github.io/solidpodeg/redirect.html", - "com.example.solidpodeg://redirect", - "http://localhost:4400/redirect.html" - ], - "post_logout_redirect_uris": [ - "https://anusii.github.io/solidpodeg/redirect.html", - "com.example.solidpodeg://redirect", - "http://localhost:4400/redirect.html" - ], - "scope": "openid profile offline_access webid", - "grant_types": [ - "authorization_code", - "refresh_token" - ], - "response_types": [ - "code" - ], - "token_endpoint_auth_method": "none" -} diff --git a/example/lib/constants/app.dart b/example/lib/constants/app.dart deleted file mode 100644 index aaf133a0..00000000 --- a/example/lib/constants/app.dart +++ /dev/null @@ -1,87 +0,0 @@ -/// Constants used throughout the app. -/// -// Time-stamp: -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Graham Williams - -library; - -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; - -const titleBackgroundColor = Color(0xFFF0E4D7); - -class AppConstants { - static const shortName = 'SolidPodEg'; - static const longName = 'Solid Pod Demonstrator'; -} - -// const dataFile = 'key-value.ttl'; -const dataFile = 'keyvalue/key-value.ttl'; - -//const dataFilePlain = 'key-value-plain.ttl'; -const dataFilePlain = dataFile; - -String createDemoTtlStr(String fileName) { - return '''@prefix demo: <#> . -@prefix rdfs: . -@prefix foaf: . - -demo:sampleData$fileName a demo:DemoResource ; - rdfs:label "Demo File $fileName" ; - demo:created "${DateTime.now().toIso8601String()}" ; - demo:description "This is a file containing some demo ttl content" ; - foaf:maker "Solid Demo" . - -demo:exampleData$fileName - demo:sampleProperty "Sample value" ; - demo:category "demo-data". -'''; -} - -/// URL of the Solid-OIDC client identifier document. Must be publicly readable -/// and its own `client_id` field must equal this URL. The source document lives -/// in the project root as `client-profile.jsonld`. -const clientIdVal = 'https://anusii.github.io/solidpodeg/client-profile.jsonld'; - -/// Redirect URIs offered to the Solid-OIDC flow. -List get redirectUrisList { - if (kIsWeb) { - return ['${Uri.base.origin}/redirect.html']; - } - return const [ - 'com.example.solidpodeg://redirect', - 'http://localhost:4400/redirect.html', - ]; -} - -/// Post-logout redirect URIs, derived the same origin-aware way as -/// [redirectUrisList]. -List get postLogoutRedirectUrisList { - if (kIsWeb) { - return ['${Uri.base.origin}/redirect.html']; - } - return const [ - 'com.example.solidpodeg://redirect', - 'http://localhost:4400/redirect.html', - ]; -} diff --git a/example/lib/dialogs/about.dart b/example/lib/dialogs/about.dart deleted file mode 100644 index f09559e3..00000000 --- a/example/lib/dialogs/about.dart +++ /dev/null @@ -1,63 +0,0 @@ -/// about dialog for the app -/// -// Time-stamp: -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Kevin Wang - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart'; - -import 'package:solidpodeg/constants/app.dart'; - -Future aboutDialog(BuildContext context) async { - final appInfo = await getAppNameVersion(); - - // Fix the use_build_context_synchronously lint error. - - if (context.mounted) { - showAboutDialog( - context: context, - applicationName: AppConstants.shortName, - applicationVersion: appInfo.version, - applicationLegalese: '© 2024 Software Innovation Institute ANU', - applicationIcon: Image.asset( - 'assets/images/solidpodeg_logo.png', - width: 100, - height: 100, - ), - children: [ - const SizedBox( - width: 300, // Limit the width. - child: SelectableText('\nA demonstrator of SolidPod functionality.' - '\n\n' - 'SolidPodEg is a demonstrator app for the solidpod package.' - ' It provides a collection of buttons to exhibit the different' - ' calabilities provided by solidpod.\n\n' - 'Authors: Anuska Vidanage, Graham Williams, Dawei Chen.'), - ), - ], - ); - } -} diff --git a/example/lib/dialogs/alert.dart b/example/lib/dialogs/alert.dart deleted file mode 100644 index 5d80c059..00000000 --- a/example/lib/dialogs/alert.dart +++ /dev/null @@ -1,44 +0,0 @@ -/// Show an Alert dialog -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Dawei Chen - -library; - -import 'package:flutter/material.dart'; - -// Show an alert dialog -Future alert(BuildContext context, String msg, - [String title = 'Notice']) async { - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(msg), - actions: [ - ElevatedButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('OK')) - ], - )); -} diff --git a/example/lib/dialogs/file_metadata.dart b/example/lib/dialogs/file_metadata.dart deleted file mode 100644 index cb3dfd7e..00000000 --- a/example/lib/dialogs/file_metadata.dart +++ /dev/null @@ -1,80 +0,0 @@ -/// A dialog to display file metadata information. -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Dawei Chen - -library; - -import 'package:flutter/material.dart'; - -/// Shows a dialog displaying file metadata. - -void showFileMetadataDialog({ - required BuildContext context, - required String fileName, - required String contentLength, - required String lastModified, - required String contentType, - required String allowdAccess, -}) { - showDialog( - context: context, - builder: (_) => AlertDialog( - title: const Text('File Information'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _infoRow('File name', fileName), - _infoRow('Last modified', lastModified), - _infoRow('Content length', contentLength), - _infoRow('Content type', contentType), - _infoRow('Allowed operations', allowdAccess), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ); -} - -Widget _infoRow(String label, String value) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - '$label:', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - Expanded(child: Text(value)), - ], - ), - ); -} diff --git a/example/lib/features/absolute_url_demo.dart b/example/lib/features/absolute_url_demo.dart deleted file mode 100644 index 59596fb1..00000000 --- a/example/lib/features/absolute_url_demo.dart +++ /dev/null @@ -1,219 +0,0 @@ -/// A page to test read/write/delete on the POD using an absolute URL path. -/// -/// Copyright (C) 2026, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Tony Chen - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart' - show PathType, deleteFile, getDataDirPath, getFileUrl, readPod, writePod; - -/// A widget that exercises [writePod], [readPod] and [deleteFile] using an -/// absolute-URL path (`PathType.absoluteUrl`). -/// -/// The three core file operations resolve their target through -/// [PathType.absoluteUrl], which simply passes the supplied URL straight -/// through to the REST layer. This page lets a tester confirm that a file can -/// be written, read back and deleted again purely by its absolute URL, without -/// relying on any relative-path resolution. -/// -/// The URL field is pre-populated with a sensible default pointing at a plain -/// text file in the current app's data directory, but any writable absolute -/// URL on the logged-in user's own POD may be used. - -class AbsoluteUrlDemo extends StatefulWidget { - const AbsoluteUrlDemo({super.key}); - - @override - State createState() => _AbsoluteUrlDemoState(); -} - -class _AbsoluteUrlDemoState extends State { - final TextEditingController _urlController = TextEditingController(); - final TextEditingController _contentController = TextEditingController( - text: 'Hello from an absolute URL write at ${DateTime.now()}', - ); - - // Last operation outcome shown to the tester. - - String _status = ''; - - @override - void initState() { - super.initState(); - _prefillDefaultUrl(); - } - - @override - void dispose() { - _urlController.dispose(); - _contentController.dispose(); - super.dispose(); - } - - /// Resolve a default absolute URL inside the current app's data directory so - /// the tester has a ready-to-use, writable target. A plain `.txt` file is - /// used so the content round-trips verbatim (no encryption / turtle parsing). - - Future _prefillDefaultUrl() async { - try { - final url = await getFileUrl( - [await getDataDirPath(), 'absolute_url_demo.txt'].join('/'), - ); - if (mounted) { - _urlController.text = url; - } - } catch (e) { - // Leave the field empty if the URL cannot be resolved (e.g. not yet - // logged in); the tester can paste a URL manually. - - debugPrint('Could not resolve default absolute URL: $e'); - } - } - - /// Write the entered content to the absolute URL via [PathType.absoluteUrl]. - - Future _write() async { - final url = _urlController.text.trim(); - try { - await writePod( - url, - _contentController.text, - encrypted: false, - overwrite: true, - pathType: PathType.absoluteUrl, - ); - _setStatus('Write OK -> $url'); - } catch (e) { - _setStatus('Write failed: $e'); - } - } - - /// Read the file back from the absolute URL via [PathType.absoluteUrl]. - - Future _read() async { - final url = _urlController.text.trim(); - try { - final content = await readPod(url, pathType: PathType.absoluteUrl); - _setStatus('Read OK -> $url\n\n$content'); - } catch (e) { - _setStatus('Read failed: $e'); - } - } - - /// Delete the file by its absolute URL. [deleteFile] is URL-based, so the - /// absolute URL can be handed to it directly. - - Future _delete() async { - final url = _urlController.text.trim(); - try { - await deleteFile(fileUrl: url); - _setStatus('Delete OK -> $url'); - } catch (e) { - _setStatus('Delete failed: $e'); - } - } - - void _setStatus(String message) { - if (mounted) { - setState(() { - _status = message; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Absolute URL Read/Write/Delete'), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'This page calls writePod / readPod with ' - 'PathType.absoluteUrl, and deleteFile with the same absolute ' - 'URL, to verify that all three operations work against a ' - 'full resource URL on your own POD.', - style: TextStyle(fontSize: 16.0, height: 1.5), - ), - const SizedBox(height: 24), - TextField( - controller: _urlController, - decoration: const InputDecoration( - labelText: 'Absolute resource URL', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - TextField( - controller: _contentController, - decoration: const InputDecoration( - labelText: 'Content to write', - border: OutlineInputBorder(), - ), - maxLines: 3, - ), - const SizedBox(height: 24), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - ElevatedButton( - onPressed: _write, - child: const Text('Write (absoluteUrl)'), - ), - ElevatedButton( - onPressed: _read, - child: const Text('Read (absoluteUrl)'), - ), - ElevatedButton( - onPressed: _delete, - child: const Text('Delete (by URL)'), - ), - ], - ), - const SizedBox(height: 24), - if (_status.isNotEmpty) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _status, - style: const TextStyle(fontSize: 16), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/example/lib/features/check_file_encryption.dart b/example/lib/features/check_file_encryption.dart deleted file mode 100644 index b04745ee..00000000 --- a/example/lib/features/check_file_encryption.dart +++ /dev/null @@ -1,335 +0,0 @@ -/// A demo screen for the `isFileEncrypted` helper exposed by solidpod. -/// -/// 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 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart' - show - EncryptionStatus, - PathType, - getFileEncryptionStatus, - isFileEncrypted, - writePod; - -import 'package:solidpodeg/constants/app.dart'; - -/// A small demo that lets the user check whether an arbitrary file on the -/// POD is encrypted by solidpod. -/// -/// The screen also offers helpers for seeding both an encrypted and a -/// plaintext sample file so that the verification button can be tested -/// without any prior setup. - -class CheckFileEncryption extends StatefulWidget { - /// Default constructor. - - const CheckFileEncryption({super.key}); - - @override - State createState() => _CheckFileEncryptionState(); -} - -class _CheckFileEncryptionState extends State { - // Sample files seeded by the helper buttons. Both live under the app's - // data directory so the default `relativeToData` path type works out of - // the box. - - static const String _encryptedSamplePath = - 'encryption-check-demo/encrypted-sample.ttl'; - static const String _plainSamplePath = - 'encryption-check-demo/plain-sample.ttl'; - - static const String _sampleContent = '''@prefix demo: <#> . -@prefix rdfs: . - -demo:sampleResource a demo:DemoResource ; - rdfs:label "Encryption check demo" ; - demo:note "Created by the solidpod example app to exercise isFileEncrypted()." . -'''; - - final TextEditingController _pathController = - TextEditingController(text: _encryptedSamplePath); - - PathType _pathType = PathType.relativeToData; - bool _isBusy = false; - String? _lastResult; - Color _lastResultColor = Colors.black87; - - @override - void dispose() { - _pathController.dispose(); - super.dispose(); - } - - Future _runCheck() async { - final path = _pathController.text.trim(); - if (path.isEmpty) { - setState(() { - _lastResult = 'Please enter a file path before running the check.'; - _lastResultColor = Colors.red; - }); - return; - } - - setState(() { - _isBusy = true; - _lastResult = null; - }); - - try { - final status = await getFileEncryptionStatus( - path, - pathType: _pathType, - ); - - // Also call the boolean wrapper to demonstrate both APIs. - - final encryptedFlag = await isFileEncrypted(path, pathType: _pathType); - - if (!mounted) return; - setState(() { - _lastResult = _formatStatus(status, encryptedFlag); - _lastResultColor = _colourForStatus(status); - }); - } on Exception catch (e) { - if (!mounted) return; - setState(() { - _lastResult = 'Error: $e'; - _lastResultColor = Colors.red; - }); - } finally { - if (mounted) { - setState(() { - _isBusy = false; - }); - } - } - } - - Future _seedSample({required bool encrypted}) async { - final path = encrypted ? _encryptedSamplePath : _plainSamplePath; - setState(() { - _isBusy = true; - _lastResult = null; - }); - - try { - await writePod( - path, - _sampleContent, - encrypted: encrypted, - - // Avoid raising an error if the file already exists from a prior run. - - overwrite: true, - ); - - if (!mounted) return; - setState(() { - _pathController.text = path; - _pathType = PathType.relativeToData; - _lastResult = 'Seeded ${encrypted ? "encrypted" : "plaintext"} ' - 'sample at "$path". Press "Check Encryption" to verify.'; - _lastResultColor = Colors.blueGrey; - }); - } on Exception catch (e) { - if (!mounted) return; - setState(() { - _lastResult = 'Failed to seed sample: $e'; - _lastResultColor = Colors.red; - }); - } finally { - if (mounted) { - setState(() { - _isBusy = false; - }); - } - } - } - - String _formatStatus(EncryptionStatus status, bool encryptedFlag) { - switch (status) { - case EncryptionStatus.encrypted: - return 'Encrypted by solidpod.\n' - 'isFileEncrypted() returned $encryptedFlag.'; - case EncryptionStatus.notEncrypted: - return 'Plaintext (not encrypted by solidpod).\n' - 'isFileEncrypted() returned $encryptedFlag.'; - case EncryptionStatus.notExist: - return 'The resource does not exist on the POD.'; - case EncryptionStatus.forbidden: - return 'Access to the resource is forbidden for the current WebID.'; - case EncryptionStatus.unknown: - return 'Unknown error while inspecting the resource.\n' - 'Check the debug console for details.'; - } - } - - Color _colourForStatus(EncryptionStatus status) { - switch (status) { - case EncryptionStatus.encrypted: - return Colors.green.shade800; - case EncryptionStatus.notEncrypted: - return Colors.orange.shade800; - case EncryptionStatus.notExist: - case EncryptionStatus.forbidden: - case EncryptionStatus.unknown: - return Colors.red.shade800; - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: titleBackgroundColor, - title: const Text('Check File Encryption'), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'Use this screen to check whether a file on your POD has been ' - 'encrypted by SolidPod. SolidPod does not require the ' - '".enc.ttl" suffix for encrypted files, so the suffix alone is ' - 'not a reliable indicator. The check inspects the actual ' - 'Turtle metadata on the server.', - style: TextStyle(fontSize: 15, height: 1.4), - ), - const SizedBox(height: 20), - TextField( - controller: _pathController, - enabled: !_isBusy, - decoration: const InputDecoration( - labelText: 'File path or URL', - border: OutlineInputBorder(), - helperText: - 'Example: "encryption-check-demo/encrypted-sample.ttl"', - ), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - // ignore: deprecated_member_use - value: _pathType, - decoration: const InputDecoration( - labelText: 'Path type', - border: OutlineInputBorder(), - ), - items: const [ - DropdownMenuItem( - value: PathType.relativeToData, - child: Text('Relative to data dir (default)'), - ), - DropdownMenuItem( - value: PathType.relativeToApp, - child: Text('Relative to app dir'), - ), - DropdownMenuItem( - value: PathType.relativeToPod, - child: Text('Relative to POD root'), - ), - DropdownMenuItem( - value: PathType.absoluteUrl, - child: Text('Absolute URL'), - ), - ], - onChanged: _isBusy - ? null - : (value) { - if (value != null) { - setState(() { - _pathType = value; - }); - } - }, - ), - const SizedBox(height: 20), - ElevatedButton.icon( - onPressed: _isBusy ? null : _runCheck, - icon: const Icon(Icons.lock_outlined), - label: const Text('Check Encryption'), - ), - const SizedBox(height: 24), - const Divider(), - const SizedBox(height: 8), - const Text( - 'Optional helpers: seed a sample file under the data dir so ' - 'that the check above has something to inspect. Each press ' - 'overwrites the previous sample.', - style: TextStyle(fontSize: 14, height: 1.4), - ), - const SizedBox(height: 12), - Wrap( - spacing: 12, - runSpacing: 8, - children: [ - OutlinedButton.icon( - onPressed: - _isBusy ? null : () => _seedSample(encrypted: true), - icon: const Icon(Icons.lock), - label: const Text('Seed Encrypted Sample'), - ), - OutlinedButton.icon( - onPressed: - _isBusy ? null : () => _seedSample(encrypted: false), - icon: const Icon(Icons.lock_open), - label: const Text('Seed Plaintext Sample'), - ), - ], - ), - const SizedBox(height: 24), - if (_isBusy) - const Center(child: CircularProgressIndicator()) - else if (_lastResult != null) - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - border: Border.all(color: _lastResultColor), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _lastResult!, - style: TextStyle( - fontSize: 15, - color: _lastResultColor, - fontWeight: FontWeight.w600, - height: 1.4, - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/example/lib/features/create_acl_inherited_file.dart b/example/lib/features/create_acl_inherited_file.dart deleted file mode 100644 index c6c73fed..00000000 --- a/example/lib/features/create_acl_inherited_file.dart +++ /dev/null @@ -1,203 +0,0 @@ -/// A page to create resources with ACL inheritance -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://www.gnu.org/licenses/gpl-3.0.en.html. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Anushka Vidanage - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart' show writePod, setInheritKeyDir; - -import 'package:solidpodeg/constants/app.dart'; - -// A widget to create a resource with inherited ACL. -// -// The resource will be created inside a parent directory and the ACL of that -// directory will be inherited for that resource. -// -// If resource need to be encrypted, a single encryption key assigned to the -// parent directory will be used for the encryption. -class CreateAclInheritedFile extends StatefulWidget { - const CreateAclInheritedFile({super.key}); - - @override - CreateAclInheritedFileState createState() => CreateAclInheritedFileState(); -} - -class CreateAclInheritedFileState extends State { - final _formKey = GlobalKey(); - - // Controllers for the text fields - final TextEditingController _resourcePathController = TextEditingController(); - final TextEditingController _parentDirectoryController = - TextEditingController(); - - // Toggle switch value - bool _isEncrypted = true; - - @override - void dispose() { - // Dispose controllers when widget is removed - _resourcePathController.dispose(); - _parentDirectoryController.dispose(); - super.dispose(); - } - - void _submitForm() async { - if (_formKey.currentState!.validate()) { - // Retrieve entered values - String resourcePath = _resourcePathController.text.trim(); - String parentDirectory = _parentDirectoryController.text.trim(); - - final demoTtlContent = createDemoTtlStr(resourcePath); - final messenger = ScaffoldMessenger.of(context); - - try { - if (_isEncrypted) { - if (!context.mounted) return; - await writePod(resourcePath, demoTtlContent, - encrypted: _isEncrypted, - createAcl: false, - overwrite: true, - inheritKeyFrom: parentDirectory); - } else { - // First check and create the corresponding directory - await setInheritKeyDir(parentDirectory); - if (!context.mounted) return; - // ignore: use_build_context_synchronously - await writePod(resourcePath, demoTtlContent, - encrypted: _isEncrypted, createAcl: false); - } - - messenger.showSnackBar( - const SnackBar(content: Text('Resource created successfully!')), - ); - } on Object catch (e, trace) { - debugPrint(e.toString()); - debugPrint(trace.toString()); - - messenger.showSnackBar( - const SnackBar( - content: Text('There was a problem creating resource! ' - 'Please try again later.')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Create a resource with ACL inheritance'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - key: _formKey, - child: Column( - children: [ - // Instruction paragraph - const Text( - 'Fill out the following two text fields according to the below ' - 'instructions. The "Resource Path" field should contain the path ' - 'to the resource itself including the actual resource name. An ' - 'example would be "parentDir/sampleRes.ttl". The "Parent Dir"' - 'should contain the path to the actual parent directory where the ' - 'resource will inherit the ACL file from. An example would be ' - '"parentDir".', - style: TextStyle(fontSize: 16.0, height: 1.5), - ), - const SizedBox(height: 24), - - // Resource path field - TextFormField( - controller: _resourcePathController, - decoration: const InputDecoration( - labelText: 'Resource Path', - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a resource path'; - } - return null; - }, - ), - const SizedBox(height: 16), - - // Parent directory field - TextFormField( - controller: _parentDirectoryController, - decoration: const InputDecoration( - labelText: 'Parent Directory', - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a parent directory'; - } - return null; - }, - ), - const SizedBox(height: 10), - - // Encrypted Toggle Switch - SwitchListTile( - title: const Text( - 'Encrypted', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - subtitle: Text( - _isEncrypted - ? 'This resource content will be stored in encrypted form.' - : 'This resource content will not be encrypted.', - ), - value: _isEncrypted, - onChanged: (bool value) { - setState(() { - _isEncrypted = value; - }); - }, - thumbColor: WidgetStateProperty.resolveWith( - (Set states) { - if (states.contains(WidgetState.selected)) { - return Colors.green; - } - return null; - }, - ), - ), - const SizedBox(height: 24), - - // Submit Button - ElevatedButton( - onPressed: _submitForm, - child: const Text('Create resource'), - ), - ], - ), - ), - ), - ); - } -} diff --git a/example/lib/features/edit_keyvalue.dart b/example/lib/features/edit_keyvalue.dart deleted file mode 100644 index f6e510a0..00000000 --- a/example/lib/features/edit_keyvalue.dart +++ /dev/null @@ -1,280 +0,0 @@ -/// A widget to edit key/value pairs and save them in a POD. -/// -// Time-stamp: -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Dawei Chen - -library; - -import 'package:flutter/material.dart'; - -import 'package:editable/editable.dart'; -import 'package:solidpod/solidpod.dart' show isUserLoggedIn, writePod; -import 'package:solidui/solidui.dart' show getKeyFromUserIfRequired; - -import 'package:solidpodeg/constants/app.dart'; -import 'package:solidpodeg/dialogs/alert.dart'; -import 'package:solidpodeg/utils/rdf.dart'; - -class KeyValueEdit extends StatefulWidget { - /// Constructor - const KeyValueEdit( - {required this.title, - required this.fileName, - required this.child, - this.encrypted = true, - this.keyValuePairs, - super.key}); - - final String title; - final String fileName; // file to be saved in PODs - final Widget child; - final bool encrypted; - final List<({String key, dynamic value})>? - keyValuePairs; // initial key value pairs - - @override - State createState() => _KeyValueEditState(); -} - -class _KeyValueEditState extends State { - /// Create a Key for EditableState - final _editableKey = GlobalKey(); - final regExp = RegExp(r'\s+'); - static const rowKey = 'row'; // key of row index in editedRows - static const keyStr = 'key'; - static const valStr = 'value'; - final List rows = []; - final List cols = [ - {'title': 'Key', 'key': keyStr}, - {'title': 'Value', 'key': valStr}, - ]; - final dataMap = {}; - bool _isLoading = false; // Loading indicator for data submission - - @override - void initState() { - super.initState(); - - // A column is a {'title': TITLE, 'key': KEY} - // A row is a {KEY: VALUE} - - // Initialise the rows - if (widget.keyValuePairs != null) { - for (final (:key, :value) in widget.keyValuePairs!) { - rows.add({keyStr: key, valStr: value}); - } - } - - // Save initial data - for (var i = 0; i < rows.length; i++) { - dataMap[i] = (key: rows[i][keyStr], value: rows[i][valStr]); - } - } - - // Add a new row using the global key assigined to the Editable widget - // to access its current state - void _addNewRow() { - setState(() { - _editableKey.currentState?.createRow(); - }); - } - - void _saveEditedRows() { - final editedRows = _editableKey.currentState?.editedRows as List; - // print('edited_rows: ${editedRows}'); - // print('#rows: ${_editableKey.currentState?.rowCount}'); - // print('#cols: ${_editableKey.currentState?.columnCount}'); - // print('rows:'); - // print(rows); // edits are not saved in `rows' - if (editedRows.isEmpty) { - return; - } - for (final r in editedRows) { - final rowInd = r[rowKey] as int; - dataMap[rowInd] = (key: r[keyStr] as String, value: r[valStr]); - rows[rowInd] = {keyStr: r[keyStr], valStr: r[valStr]}; - } - } - - Future _alert(String msg) async => alert(context, msg); - - // Get key value pairs - Future?> _getKeyValuePairs() async { - final rowInd = dataMap.keys.toList()..sort(); - final keys = {}; - final pairs = <({String key, dynamic value})>[]; - for (final i in rowInd) { - final k = dataMap[i]!.key.trim(); - if (k.isEmpty) { - await _alert('Invalide key: "$k"'); - return null; - } - if (keys.contains(k)) { - await _alert('Invalide key: Duplicate key "$k"'); - return null; - } - if (regExp.hasMatch(k)) { - await _alert('Invalided key: Whitespace found in key "$k"'); - return null; - } - keys.add(k); - final v = dataMap[i]!.value; - pairs.add((key: k, value: v)); - } - return pairs; - } - - // Save data to PODs - Future _saveToPod(BuildContext context) async { - _saveEditedRows(); - - final pairs = await _getKeyValuePairs(); - if (dataMap.isEmpty) { - await _alert('No data to submit'); - return false; - } - - setState(() { - // Begin loading. - - _isLoading = true; - }); - - try { - // Write to POD - if (context.mounted) { - if (!await isUserLoggedIn()) { - await _alert('Please login to write data to your POD'); - return false; - } - - if (widget.encrypted) { - if (!context.mounted) return false; - await getKeyFromUserIfRequired(context, widget); - } - - // Generate TTL str with dataMap - final ttlStr = await genTTLStr(pairs!); - - try { - await writePod( - widget.fileName, - ttlStr, - encrypted: widget.encrypted, - overwrite: true, - ); - - await _alert('Successfully saved ${dataMap.length} key-value pairs' - ' to "${widget.fileName}" in PODs'); - return true; - } on Object catch (e, trace) { - debugPrint(e.toString()); - debugPrint(trace.toString()); - - await _alert('Something went wrong. Please try again!'); - return false; - } - } - } on Exception catch (e) { - debugPrint('Exception: $e'); - } finally { - if (mounted) { - setState(() { - // End loading. - - _isLoading = false; - }); - } - } - return false; - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(widget.title), - backgroundColor: titleBackgroundColor, - leadingWidth: 100, - actions: [ - Padding( - padding: const EdgeInsets.all(8), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - TextButton.icon( - onPressed: _addNewRow, - icon: const Icon(Icons.add), - label: const Text('Add', - style: TextStyle(fontWeight: FontWeight.bold)), - ), - const SizedBox(width: 10), - ElevatedButton( - onPressed: () async { - final saved = await _saveToPod(context); - if (saved) { - if (!context.mounted) return; - await Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => widget.child)); - } - }, - child: const Text('Submit', - style: TextStyle(fontWeight: FontWeight.bold))), - ])), - ], - ), - body: Center( - child: _isLoading - ? const CircularProgressIndicator() // Show loading indicator - : Editable( - key: _editableKey, - columns: cols, - rows: rows, - // zebraStripe: false, - // stripeColor1: Colors.blue[50]!, - // stripeColor2: Colors.grey[200]!, - onRowSaved: print, - onSubmitted: print, - borderColor: Colors.blueGrey, - tdStyle: const TextStyle(fontWeight: FontWeight.bold), - trHeight: 20, - thStyle: const TextStyle( - fontSize: 15, fontWeight: FontWeight.bold), - thAlignment: TextAlign.center, - thVertAlignment: CrossAxisAlignment.end, - thPaddingBottom: 3, - // showSaveIcon: - // false, // do not show the save icon at the right of a row - // saveIconColor: Colors.black, - // showCreateButton: false, // do not show the + button at top-left - tdAlignment: TextAlign.left, - tdEditableMaxLines: 100, // don't limit and allow data to wrap - tdPaddingTop: 5, - tdPaddingBottom: 5, - focusedBorder: const OutlineInputBorder( - borderSide: BorderSide(color: Colors.blue), - borderRadius: BorderRadius.zero), - ), - )); - } -} diff --git a/example/lib/features/file_service.dart b/example/lib/features/file_service.dart deleted file mode 100644 index 13fe0388..00000000 --- a/example/lib/features/file_service.dart +++ /dev/null @@ -1,639 +0,0 @@ -/// A widget to demonstrate the upload, download, and delete large files. -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Dawei Chen - -library; - -import 'package:flutter/material.dart'; - -import 'package:file_picker/file_picker.dart'; -import 'package:solidpod/solidpod.dart'; - -import 'package:solidpodeg/dialogs/alert.dart'; -import 'package:solidpodeg/widgets/file_service_sections.dart'; - -class FileService extends StatefulWidget { - const FileService({required this.child, required this.webId, super.key}); - final String webId; - final Widget child; - - @override - State createState() => _FileServiceState(); -} - -class _FileServiceState extends State { - String defaultRemoteFileName = 'large_file.bin'; - String? uploadFile; - String? uploadSharedFile; - String? downloadFile; - String? downloadSharedFile; - - double uploadPercent = 0.0; - double uploadSharedPercent = 0.0; - double downloadPercent = 0.0; - double downloadSharedPercent = 0.0; - double deletePercent = 0.0; - - bool uploadDone = false; - bool uploadSharedDone = false; - bool downloadDone = false; - bool downloadSharedDone = false; - bool deleteDone = false; - - bool uploadInProgress = false; - bool uploadSharedInProgress = false; - bool downloadInProgress = false; - bool downloadSharedInProgress = false; - bool deleteInProgress = false; - - final remoteFolderController = TextEditingController(); - final keyRefFolderController = TextEditingController(); - final sharedUrlController = TextEditingController(); - final uploadSharedUrlController = TextEditingController(); - final uploadSharedKeyRefController = TextEditingController(); - - final smallGapH = const SizedBox(width: 10); - final smallGapV = const SizedBox(height: 10); - final largeGapV = const SizedBox(height: 30); - - String getRemoteFileName() => - '${remoteFolderController.text.trim()}$defaultRemoteFileName'; - - String? getKeyRefPath() { - final folder = keyRefFolderController.text.trim(); - return folder.isNotEmpty ? folder : null; - } - - String? getUploadSharedKeyRefPath() { - final folder = uploadSharedKeyRefController.text.trim(); - return folder.isNotEmpty ? folder : null; - } - - /// Parse an external large-file URL of the form - /// `https://SERVER/POD_NAME/APP_NAME/data/FILE_PATH` into the owner's WebID - /// and the path of the file relative to the POD root. - /// - /// The returned [podRelativePath] keeps the `APP_NAME/data/FILE_PATH` - /// segments verbatim, so the file is read/written at exactly the URL the - /// user supplied (rather than under the current app's own data directory). - /// Pass it to the large-file APIs together with `isPodRelativePath: true`. - /// - /// Throws a [FormatException] with an actionable message if [url] is not a - /// valid http(s) URL with at least the four expected path segments. We - /// validate explicitly (rather than via `assert`) so the error reaches the - /// user instead of crashing in debug or silently misbehaving in release. - - ({String ownerWebId, String podRelativePath}) parseExternalFileUrl( - String url) { - final uri = Uri.parse(url); - - // Ignore any empty segments produced by leading/trailing slashes. - // Expected: [POD_NAME, APP_NAME, data, FILE_PATH, ...]. - - final segments = - uri.pathSegments.where((s) => s.isNotEmpty).toList(growable: false); - - if ((uri.scheme != 'http' && uri.scheme != 'https') || - uri.host.isEmpty || - segments.length < 4) { - throw const FormatException( - 'Invalid destination URL. Expected the form ' - 'https://SERVER/POD_NAME/APP_NAME/data/FILE_PATH', - ); - } - - final podName = segments.first; - final ownerWebId = [uri.origin, podName, 'profile/card#me'].join('/'); - - // Everything after POD_NAME is relative to the POD root, i.e. - // APP_NAME/data/FILE_PATH. - - final podRelativePath = segments.skip(1).join('/'); - - return (ownerWebId: ownerWebId, podRelativePath: podRelativePath); - } - - @override - void initState() { - super.initState(); - remoteFolderController.addListener(() => remoteFolderController.value = - remoteFolderController.value.copyWith( - text: _sanitiseFolderPath(remoteFolderController.text.trim()))); - keyRefFolderController.addListener(() => keyRefFolderController.value = - keyRefFolderController.value.copyWith( - text: _sanitiseFolderPath(keyRefFolderController.text.trim()))); - } - - @override - void dispose() { - remoteFolderController.dispose(); - keyRefFolderController.dispose(); - sharedUrlController.dispose(); - uploadSharedUrlController.dispose(); - uploadSharedKeyRefController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final browseButton = ElevatedButton( - onPressed: () async { - final result = await FilePicker.pickFiles(); - if (result != null) { - setState(() { - uploadFile = result.files.single.path!; - uploadDone = false; - uploadPercent = 0.0; - }); - } - }, - child: const Text('Browse'), - ); - - final uploadButton = ElevatedButton( - onPressed: (uploadFile == null || - uploadInProgress || - uploadSharedInProgress || - downloadInProgress || - downloadSharedInProgress || - deleteInProgress) - ? null - : () async { - try { - setState(() { - uploadInProgress = true; - }); - - final keyPath = getKeyRefPath(); - - if (keyPath != null) { - await setInheritKeyDir(keyPath, createAcl: true); - } - - if (!context.mounted) return; - - await writeLargeFile( - localFilePath: uploadFile!, - remoteFilePath: getRemoteFileName(), - inheritKeyFrom: keyPath, - createAcl: false, - onProgress: (sent, total) { - setState(() { - uploadDone = sent == total; - uploadPercent = sent / total; - }); - }); - if (uploadDone) { - setState(() { - uploadInProgress = false; - }); - } - } on Object catch (e) { - setState(() { - uploadFile = null; - uploadInProgress = false; - }); - if (context.mounted) { - await alert(context, 'Failed to send file. $e', 'Error'); - } - debugPrint('$e'); - } - }, - child: const Text('Upload'), - ); - - final browseSharedButton = ElevatedButton( - onPressed: () async { - final result = await FilePicker.pickFiles(); - if (result != null) { - setState(() { - uploadSharedFile = result.files.single.path!; - uploadSharedDone = false; - uploadSharedPercent = 0.0; - }); - } - }, - child: const Text('Browse'), - ); - - final uploadSharedButton = ElevatedButton( - onPressed: (uploadSharedFile == null || - uploadInProgress || - uploadSharedInProgress || - downloadInProgress || - downloadSharedInProgress || - deleteInProgress) - ? null - : () async { - try { - final destUrl = uploadSharedUrlController.text.trim(); - if (destUrl.isEmpty) { - const msg = 'Destination URL is empty'; - await alert(context, msg); - throw Exception(msg); - } - - setState(() { - uploadSharedInProgress = true; - }); - - final parsed = parseExternalFileUrl(destUrl); - final keyPath = getUploadSharedKeyRefPath(); - - if (!context.mounted) return; - - await writeLargeFile( - localFilePath: uploadSharedFile!, - remoteFilePath: parsed.podRelativePath, - isPodRelativePath: true, - ownerWebId: parsed.ownerWebId, - inheritKeyFrom: keyPath, - createAcl: false, - encrypted: keyPath != null, - onProgress: (sent, total) { - setState(() { - uploadSharedDone = sent == total; - uploadSharedPercent = sent / total; - }); - }); - if (uploadSharedDone) { - setState(() { - uploadSharedInProgress = false; - }); - } - } on Object catch (e) { - setState(() { - uploadSharedInProgress = false; - }); - if (context.mounted) { - await alert(context, - 'Failed to upload file to external POD. $e', 'Error'); - } - debugPrint('$e'); - } - }, - child: const Text('Upload to External POD'), - ); - - final downloadButton = ElevatedButton( - onPressed: (uploadInProgress || - downloadInProgress || - downloadSharedInProgress || - deleteInProgress) - ? null - : () async { - String? outputFile = await FilePicker.saveFile( - dialogTitle: 'Please set the output file:', - // fileName: 'download.bin', - ); - - if (outputFile == null) { - // User canceled the picker - debugPrint('Download is cancelled'); - } else { - setState(() { - downloadFile = outputFile; - }); - try { - // remoteFileUrl ??= await getRemoteFileUrl(); - setState(() { - downloadInProgress = true; - }); - - await readLargeFile( - remoteFilePath: getRemoteFileName(), - localFilePath: outputFile, - onProgress: (received, total) { - setState(() { - downloadDone = received == total; - downloadPercent = received / total; - }); - }); - - if (downloadDone) { - setState(() { - downloadInProgress = false; - }); - } - } on Object catch (e) { - setState(() { - downloadFile = null; - downloadInProgress = false; - }); - if (context.mounted) { - await alert( - context, 'Failed to download file. $e', 'Error'); - } - debugPrint('$e'); - } - } - }, - child: const Text('Download'), - ); - - final downloadSharedButton = ElevatedButton( - onPressed: (uploadInProgress || - downloadInProgress || - downloadSharedInProgress || - deleteInProgress) - ? null - : () async { - String? outputFile = await FilePicker.saveFile( - dialogTitle: 'Please set the output file:', - ); - if (outputFile == null) { - // User canceled the picker - debugPrint('Download is cancelled'); - } else { - setState(() { - downloadSharedFile = outputFile; - }); - try { - setState(() { - downloadSharedInProgress = true; - }); - - final sharedFileUrl = sharedUrlController.text.trim(); - if (sharedFileUrl.isEmpty) { - final msg = 'Shared file URL is empty'; - if (context.mounted) await alert(context, msg); - throw Exception(msg); - } - - // URL format: - // https://SERVER_URL/POD_NAME/APP_NAME/data/FILE_PATH - final parsed = parseExternalFileUrl(sharedFileUrl); - - if (context.mounted) { - await readLargeFile( - remoteFilePath: parsed.podRelativePath, - isPodRelativePath: true, - localFilePath: outputFile, - ownerWebId: parsed.ownerWebId, - onProgress: (received, total) { - setState(() { - downloadSharedDone = received == total; - downloadSharedPercent = received / total; - }); - }); - if (downloadDone) { - setState(() { - downloadSharedInProgress = false; - }); - } - } - } on Object catch (e) { - setState(() { - downloadSharedFile = null; - downloadSharedInProgress = false; - }); - if (context.mounted) { - await alert( - context, 'Failed to download shared file. $e', 'Error'); - } - debugPrint('$e'); - } - } - }, - child: const Text('Download Shared Large File'), - ); - - final deleteButton = ElevatedButton( - onPressed: (uploadInProgress || - downloadInProgress || - downloadSharedInProgress || - deleteInProgress) - ? null - : () async { - try { - // remoteFileUrl ??= await getRemoteFileUrl(); - setState(() { - deleteInProgress = true; - }); - await deleteLargeFile( - remoteFilePath: getRemoteFileName(), - onProgress: (deleted, total) { - setState(() { - deleteDone = deleted == total; - deletePercent = deleted / total; - }); - }); - if (deleteDone) { - setState(() { - deleteInProgress = false; - }); - } - } on Object catch (e) { - setState(() { - deleteInProgress = false; - }); - if (context.mounted) { - await alert(context, 'Failed to delete file. $e', 'Error'); - } - debugPrint('$e'); - } - }, - child: const Text('Delete'), - ); - - // Widgets of the file upload section - - final uploadSection = buildUploadSectionUI( - defaultRemoteFileName: defaultRemoteFileName, - uploadFile: uploadFile, - uploadDone: uploadDone, - uploadInProgress: uploadInProgress, - remoteFolderController: remoteFolderController, - keyRefFolderController: keyRefFolderController, - browseButton: browseButton, - uploadButton: uploadButton, - ); - - // Widgets of the upload-to-external-POD section - - final uploadSharedSection = buildUploadSharedSectionUI( - uploadSharedFile: uploadSharedFile, - uploadSharedDone: uploadSharedDone, - uploadSharedInProgress: uploadSharedInProgress, - uploadSharedUrlController: uploadSharedUrlController, - uploadSharedKeyRefController: uploadSharedKeyRefController, - browseSharedButton: browseSharedButton, - uploadSharedButton: uploadSharedButton, - ); - - // Widgets of the file download section - - final downloadSection = buildDownloadSectionUI( - defaultRemoteFileName: defaultRemoteFileName, - downloadFile: downloadFile, - downloadDone: downloadDone, - downloadButton: downloadButton, - ); - - // Widgets of the shared file download section - - final downloadSharedSection = buildDownloadSharedSectionUI( - downloadSharedFile: downloadSharedFile, - downloadSharedDone: downloadSharedDone, - downloadSharedInProgress: downloadSharedInProgress, - sharedUrlController: sharedUrlController, - downloadSharedButton: downloadSharedButton, - ); - - // Widgets of the file delete section - - final deleteSection = buildDeleteSectionUI( - defaultRemoteFileName: defaultRemoteFileName, - deleteInProgress: deleteInProgress, - deleteDone: deleteDone, - deleteButton: deleteButton, - ); - - return Scaffold( - body: Padding( - padding: const EdgeInsets.all(10), - child: Stack( - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - largeGapV, - largeGapV, - - // Upload - - ...uploadSection, - - largeGapV, - - // Upload to external POD - - ...uploadSharedSection, - - largeGapV, - - // Download - - ...downloadSection, - - largeGapV, - - // Delete - - ...deleteSection, - - largeGapV, - - // Download shared file - - ...downloadSharedSection, - ], - ), - - // Uploading progress bar - - if (uploadInProgress) - Positioned( - top: 20, - left: 0, - right: 0, - child: - buildProgressBar('Uploading:', uploadDone, uploadPercent), - ), - - // Uploading to external POD progress bar - - if (uploadSharedInProgress) - Positioned( - top: 20, - left: 0, - right: 0, - child: buildProgressBar( - 'Uploading:', uploadSharedDone, uploadSharedPercent), - ), - - // Downloading progress bar - - if (downloadInProgress) - Positioned( - top: 20, - left: 0, - right: 0, - child: buildProgressBar( - 'Downloading:', downloadDone, downloadPercent), - ), - - // Downloading shared file progress bar - - if (downloadSharedInProgress) - Positioned( - top: 20, - left: 0, - right: 0, - child: buildProgressBar( - 'Downloading:', downloadSharedDone, downloadSharedPercent), - ), - - // Deleting progress bar - - if (deleteInProgress) - Positioned( - top: 20, - left: 0, - right: 0, - child: buildProgressBar('Deleting:', deleteDone, deletePercent), - ), - - // Navigate back to demo page - Positioned( - top: 10, - left: 10, - child: ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('Back to Demo'), - ), - ), - - // Widget to show Web ID - Positioned( - bottom: 10, - right: 10, - child: Text( - 'WEB ID - ${widget.webId}', - style: const TextStyle( - color: Colors.blue, - fontWeight: FontWeight.bold, - ), - ), - ) - ], - ), - ), - ); - } -} - -String _sanitiseFolderPath(String folderPath) { - final folder = folderPath.endsWith('/') ? folderPath : '$folderPath/'; - - return folder.startsWith('/') ? folder.substring(1) : folder; -} diff --git a/example/lib/features/load_test.dart b/example/lib/features/load_test.dart deleted file mode 100644 index 6800ae01..00000000 --- a/example/lib/features/load_test.dart +++ /dev/null @@ -1,649 +0,0 @@ -/// A screen to drive the headless Solid load tester and visualise its progress. -/// -/// Copyright (C) 2026, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Tony Chen - -library; - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; - -/// A demonstrator screen that launches `loadtest/solid_load_test.py` and renders -/// its JSON-Lines progress in real time. -/// -/// The heavy lifting (creating up to ~100 accounts and Pods, logging in, and -/// reading/writing resources concurrently) lives in the Python script so the -/// test runs headlessly and scales well beyond what interactive in-app OIDC -/// login could manage. This screen is purely a launcher and live dashboard: it -/// spawns the script with `--json`, streams its stdout, and updates as each -/// `phase`, `progress` and `summary` event arrives. -/// -/// Spawning a child process needs `dart:io`, so the feature is only available on -/// the desktop builds of the demonstrator (it is disabled on web). - -class LoadTest extends StatefulWidget { - /// Initialise widget variables. - - const LoadTest({super.key}); - - @override - State createState() => _LoadTestState(); -} - -class _LoadTestState extends State { - // The default target server under test. - - static const _defaultServer = 'https://solid.dev.empwr.au'; - - // Form controllers. The interpreter and script paths are resolved to absolute - // paths in initState by walking up from the running executable (see - // _resolveDefaults); the values below are only fallbacks for when that - // resolution fails (e.g. a relocated release build). Both can be edited. - - final _serverCtrl = TextEditingController(text: _defaultServer); - final _usersCtrl = TextEditingController(text: '20'); - final _concurrencyCtrl = TextEditingController(text: '10'); - final _resourcesCtrl = TextEditingController(text: '3'); - final _prefixCtrl = TextEditingController(text: 'loadtest'); - final _domainCtrl = TextEditingController(text: 'example.org'); - final _passwordCtrl = TextEditingController(text: 'Load-Test-Pw-1!'); - final _pythonCtrl = TextEditingController(text: 'python3'); - final _scriptCtrl = - TextEditingController(text: 'loadtest/solid_load_test.py'); - - // The resolved example-app directory (the parent of loadtest/), if located by - // walking up from the running executable. Used as the working directory for - // the spawned script so it can find its virtualenv and resources. - - String? _exampleDir; - - bool _changePassword = false; - bool _encrypt = true; - - // Live run state. - - Process? _process; - bool get _running => _process != null; - - int _completed = 0; - int _total = 0; - int _ok = 0; - int _failed = 0; - - // The most recent summary, parsed from the final `summary` event. - - Map? _summary; - - // A rolling log of recent events shown in the bottom pane. Capped so a long - // run does not grow the list without bound. - - final List _log = []; - static const _maxLogLines = 400; - - final _logScrollCtrl = ScrollController(); - - @override - void initState() { - super.initState(); - _resolveDefaults(); - } - - // Resolve absolute defaults for the script and interpreter paths. - // - // The process working directory on macOS is not the project directory (under - // the App Sandbox it is the container root), so a relative script path cannot - // be relied upon. Instead the load tester is located on disk relative to the - // running executable and the resulting absolute path is used. - - void _resolveDefaults() { - if (kIsWeb) return; - final dir = _findExampleDir(); - if (dir == null) return; - _exampleDir = dir; - - final script = '$dir/loadtest/solid_load_test.py'; - if (File(script).existsSync()) { - _scriptCtrl.text = script; - } - - // Prefer the project's virtualenv interpreter (created per - // loadtest/README.md) so the third-party dependencies (httpx, cryptography) - // are available without relying on a global Python installation. - - for (final candidate in [ - '$dir/loadtest/.venv/bin/python3', - '$dir/loadtest/.venv/bin/python', - ]) { - if (File(candidate).existsSync()) { - _pythonCtrl.text = candidate; - break; - } - } - } - - // Walk up from the running executable looking for the directory that contains - // loadtest/solid_load_test.py. When run with `flutter run -d macos` the - // executable lives several levels below the example directory inside - // build/macos/..., so a bounded upward search reliably finds it. Returns null - // if no such directory is found. - - String? _findExampleDir() { - var dir = File(Platform.resolvedExecutable).parent; - for (var i = 0; i < 12; i++) { - if (File('${dir.path}/loadtest/solid_load_test.py').existsSync()) { - return dir.path; - } - final parent = dir.parent; - if (parent.path == dir.path) break; // Reached the filesystem root. - dir = parent; - } - return null; - } - - @override - void dispose() { - // Make sure a running test does not outlive the screen. - - _process?.kill(); - _serverCtrl.dispose(); - _usersCtrl.dispose(); - _concurrencyCtrl.dispose(); - _resourcesCtrl.dispose(); - _prefixCtrl.dispose(); - _domainCtrl.dispose(); - _passwordCtrl.dispose(); - _pythonCtrl.dispose(); - _scriptCtrl.dispose(); - _logScrollCtrl.dispose(); - super.dispose(); - } - - // Append a line to the rolling log and scroll to the bottom. - - void _appendLog(String line) { - setState(() { - _log.add(line); - if (_log.length > _maxLogLines) { - _log.removeRange(0, _log.length - _maxLogLines); - } - }); - // Defer the scroll until after the new line has been laid out. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_logScrollCtrl.hasClients) { - _logScrollCtrl.jumpTo(_logScrollCtrl.position.maxScrollExtent); - } - }); - } - - // Build the script arguments from the current form values. - - List _buildArgs() { - final args = [ - _scriptCtrl.text.trim(), - '--json', - '--server', - _serverCtrl.text.trim(), - '--users', - _usersCtrl.text.trim(), - '--concurrency', - _concurrencyCtrl.text.trim(), - '--resources', - _resourcesCtrl.text.trim(), - '--prefix', - _prefixCtrl.text.trim(), - '--email-domain', - _domainCtrl.text.trim(), - '--password', - _passwordCtrl.text, - ]; - if (_changePassword) args.add('--change-password'); - if (!_encrypt) args.add('--no-encrypt'); - return args; - } - - // Launch the Python load tester and wire up its output streams. - - Future _startRun() async { - if (_running) return; - - // Reset the dashboard for the new run. - - setState(() { - _completed = 0; - _total = int.tryParse(_usersCtrl.text.trim()) ?? 0; - _ok = 0; - _failed = 0; - _summary = null; - _log.clear(); - }); - - final python = _pythonCtrl.text.trim(); - final args = _buildArgs(); - _appendLog('\$ $python ${args.join(' ')}'); - - Process process; - try { - process = await Process.start( - python, - args, - workingDirectory: _exampleDir, - ); - } on Object catch (e) { - _appendLog('Failed to start the load tester: $e'); - _appendLog( - 'Check that the Python executable and script path above are correct ' - '(absolute paths are filled in automatically when the loadtest/ ' - 'directory is found), and that the dependencies in ' - 'loadtest/requirements.txt are installed in that interpreter.', - ); - return; - } - - setState(() { - _process = process; - }); - - // Parse each line of stdout as a JSON event. - - process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen(_handleLine); - - // Surface anything on stderr (e.g. a Python traceback) in the log. - - process.stderr - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) => _appendLog('stderr: $line')); - - // Clear the running state once the process exits. - - unawaited(process.exitCode.then((code) { - _appendLog('Load tester exited with code $code.'); - if (mounted) { - setState(() { - _process = null; - }); - } - })); - } - - // Stop a run in progress. - - void _stopRun() { - _process?.kill(); - _appendLog('Stopping load tester...'); - } - - // Decode and act on a single JSON-Lines event from the script. - - void _handleLine(String line) { - if (line.trim().isEmpty) return; - - Map event; - try { - event = jsonDecode(line) as Map; - } on Object { - // Not a JSON line (e.g. a stray print); show it verbatim. - - _appendLog(line); - return; - } - - switch (event['event']) { - case 'start': - _appendLog( - 'Started: ${event['users']} users, concurrency ' - '${event['concurrency']}, server ${event['server']}', - ); - - case 'phase': - final status = (event['status'] as String).toUpperCase(); - final ms = event['ms']; - final detail = event['detail']; - _appendLog( - ' [user ${event['user']}] ${event['step']} $status' - '${ms == null ? '' : ' (${ms}ms)'}' - '${detail == null ? '' : ' - $detail'}', - ); - - case 'user_done': - final outcome = (event['ok'] as bool) ? 'OK' : 'FAILED'; - _appendLog( - '[user ${event['user']}] ${event['email']} -> $outcome ' - '(${event['total_ms']}ms)', - ); - - case 'progress': - setState(() { - _completed = event['completed'] as int; - _total = event['total'] as int; - _ok = event['ok'] as int; - _failed = event['failed'] as int; - }); - - case 'summary': - setState(() { - _summary = event; - }); - _appendLog('Run complete.'); - - case 'error': - _appendLog('ERROR: ${event['message']}'); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Load Testing'), - ), - body: kIsWeb - // Spawning the Python script needs dart:io, which is unavailable on - // web, so the launcher is disabled there. - ? const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - 'The load tester launches a local Python script and is only ' - 'available on the desktop builds of this demonstrator. Run ' - 'the script directly from loadtest/ on web.', - textAlign: TextAlign.center, - ), - ), - ) - : _buildBody(context), - ); - } - - Widget _buildBody(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Stress test of Pod hosting, login and read/write access at scale.', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - const Text( - 'Each simulated user creates an account and Pod, logs in, ' - 'generates a security key, then writes and reads resources. ' - 'The work is performed by loadtest/solid_load_test.py; install ' - 'its requirements first (see loadtest/README.md).', - style: TextStyle(fontSize: 13), - ), - const SizedBox(height: 16), - - // Test parameters, laid out as a wrapping grid of small fields. - - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - _field(_serverCtrl, 'Server URL', width: 320), - _field(_usersCtrl, 'Users', width: 110, number: true), - _field(_concurrencyCtrl, 'Concurrency', width: 130, number: true), - _field(_resourcesCtrl, 'Resources/user', - width: 140, number: true), - _field(_prefixCtrl, 'Account prefix', width: 160), - _field(_domainCtrl, 'Email domain', width: 200), - _field(_passwordCtrl, 'Password', width: 200), - ], - ), - const SizedBox(height: 12), - - // Advanced toggles. - - Wrap( - spacing: 16, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _toggle( - 'Also change password', - _changePassword, - (v) => setState(() => _changePassword = v), - ), - _toggle( - 'Encrypt resources', - _encrypt, - (v) => setState(() => _encrypt = v), - ), - ], - ), - const SizedBox(height: 12), - - // Runner configuration (interpreter + script path). - - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - _field(_pythonCtrl, 'Python executable', width: 220), - _field(_scriptCtrl, 'Script path', width: 360), - ], - ), - const SizedBox(height: 16), - - // Run / stop controls. - - Row( - children: [ - ElevatedButton.icon( - onPressed: _running ? null : _startRun, - icon: const Icon(Icons.play_arrow), - label: const Text('Run Load Test'), - ), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: _running ? _stopRun : null, - icon: const Icon(Icons.stop), - label: const Text('Stop'), - ), - const SizedBox(width: 16), - if (_running) - const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ], - ), - const SizedBox(height: 16), - - _buildProgress(context), - const SizedBox(height: 12), - - if (_summary != null) ...[ - _buildSummary(context), - const SizedBox(height: 12), - ], - - _buildLog(context), - ], - ), - ); - } - - Widget _buildProgress(BuildContext context) { - final fraction = _total == 0 ? 0.0 : _completed / _total; - - // Before a run has started the bar is an inert grey track at zero rather - // than the animated indeterminate state, so it reads as disabled. - - final idle = !_running && _completed == 0 && _summary == null; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LinearProgressIndicator( - value: fraction, - color: idle ? Theme.of(context).disabledColor : null, - ), - const SizedBox(height: 6), - Text( - 'Completed $_completed of $_total ' - '(ok $_ok, failed $_failed)', - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ], - ); - } - - Widget _buildSummary(BuildContext context) { - final s = _summary!; - final steps = (s['steps'] as Map?) ?? {}; - - // One row per lifecycle step with its mean/p95/max and failure count. - - final rows = []; - steps.forEach((step, value) { - final st = value as Map; - if ((st['count'] as int) == 0 && (st['failures'] as int) == 0) return; - rows.add(DataRow(cells: [ - DataCell(Text(step)), - DataCell(Text('${st['mean']}')), - DataCell(Text('${st['p95']}')), - DataCell(Text('${st['max']}')), - DataCell(Text('${st['failures']}')), - ])); - }); - - return Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Summary', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text('Users: ${s['users_ok']} ok / ${s['users_failed']} failed ' - 'of ${s['users']}'), - Text('Resources written: ${s['resources_written']}, ' - 'read back: ${s['resources_read']}'), - Text('Wall-clock time: ${s['wall_ms']} ms'), - const SizedBox(height: 8), - const Text( - 'Per-step timing (ms)', - style: TextStyle(fontWeight: FontWeight.bold), - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: DataTable( - columns: const [ - DataColumn(label: Text('Step')), - DataColumn(label: Text('Mean')), - DataColumn(label: Text('p95')), - DataColumn(label: Text('Max')), - DataColumn(label: Text('Fail')), - ], - rows: rows, - ), - ), - ], - ), - ), - ); - } - - Widget _buildLog(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Live log', - style: TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 6), - Container( - height: 240, - width: double.infinity, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.black87, - borderRadius: BorderRadius.circular(6), - ), - child: Scrollbar( - controller: _logScrollCtrl, - child: ListView.builder( - controller: _logScrollCtrl, - itemCount: _log.length, - itemBuilder: (context, i) => Text( - _log[i], - style: const TextStyle( - color: Colors.greenAccent, - fontFamily: 'monospace', - fontSize: 12, - ), - ), - ), - ), - ), - ], - ); - } - - // A labelled text field of a fixed width for the parameter grid. - - Widget _field( - TextEditingController controller, - String label, { - required double width, - bool number = false, - }) { - return SizedBox( - width: width, - child: TextField( - controller: controller, - enabled: !_running, - keyboardType: number ? TextInputType.number : TextInputType.text, - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - isDense: true, - ), - ), - ); - } - - Widget _toggle(String label, bool value, ValueChanged onChanged) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: value, - onChanged: _running ? null : (v) => onChanged(v ?? false), - ), - Text(label), - ], - ); - } -} diff --git a/example/lib/features/manage_acl_folder.dart b/example/lib/features/manage_acl_folder.dart deleted file mode 100644 index 446f3570..00000000 --- a/example/lib/features/manage_acl_folder.dart +++ /dev/null @@ -1,232 +0,0 @@ -/// A page to create and delete folders (containers) that carry their own ACL. -/// -/// Copyright (C) 2026, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Tony Chen - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart' show createContainer, deleteContainer; - -/// A widget to create or delete a folder (container) on the POD. -/// -/// Folders created here are given their own `.acl` file (via [createContainer]) -/// so that the folder, and any resources placed within it, can be shared -/// independently of the parent folder. - -class ManageAclFolder extends StatefulWidget { - const ManageAclFolder({super.key}); - - @override - ManageAclFolderState createState() => ManageAclFolderState(); -} - -class ManageAclFolderState extends State { - final _formKey = GlobalKey(); - - // Controllers for the text fields. - - final TextEditingController _parentPathController = TextEditingController(); - final TextEditingController _folderNameController = TextEditingController(); - - // Whether an operation is currently running, used to disable the buttons. - - bool _busy = false; - - @override - void dispose() { - // Dispose controllers when the widget is removed. - - _parentPathController.dispose(); - _folderNameController.dispose(); - super.dispose(); - } - - // Creates the folder together with its `.acl` file. - - Future _createFolder() async { - if (!(_formKey.currentState?.validate() ?? false)) return; - - final parentPath = _parentPathController.text.trim(); - final folderName = _folderNameController.text.trim(); - final messenger = ScaffoldMessenger.of(context); - - setState(() => _busy = true); - try { - // createAcl defaults to true, so the new folder receives its own `.acl`. - - await createContainer(parentPath, folderName); - - messenger.showSnackBar( - SnackBar( - content: Text('Folder "$folderName" created with an ACL file.'), - ), - ); - } on Object catch (e, trace) { - debugPrint(e.toString()); - debugPrint(trace.toString()); - - messenger.showSnackBar( - SnackBar(content: Text('Failed to create folder: $e')), - ); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - // Deletes the folder and all of its contents (including its `.acl`). - - Future _deleteFolder() async { - if (!(_formKey.currentState?.validate() ?? false)) return; - - final parentPath = _parentPathController.text.trim(); - final folderName = _folderNameController.text.trim(); - final messenger = ScaffoldMessenger.of(context); - - // Ask for confirmation since deletion is recursive and irreversible. - - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('Delete folder?'), - content: Text( - 'This will permanently delete the folder "$folderName" and all of ' - 'its contents, including its ACL file. This cannot be undone.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: const Text('Delete'), - ), - ], - ), - ); - - if (confirmed != true) return; - - setState(() => _busy = true); - try { - await deleteContainer(parentPath, folderName); - - messenger.showSnackBar( - SnackBar(content: Text('Folder "$folderName" deleted.')), - ); - } on Object catch (e, trace) { - debugPrint(e.toString()); - debugPrint(trace.toString()); - - messenger.showSnackBar( - SnackBar(content: Text('Failed to delete folder: $e')), - ); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Create / delete a folder with ACL'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Instruction paragraph. - - const Text( - 'Create a folder (container) on your Pod. The folder is given ' - 'its own ACL file so it can be shared independently of its ' - 'parent folder. The "Parent Path" is the folder, relative to ' - 'your app data directory, that will contain the new folder ' - '(leave it empty to create the folder directly in the data ' - 'directory). The "Folder Name" is the name of the folder to ' - 'create or delete.', - style: TextStyle(fontSize: 16.0, height: 1.5), - ), - const SizedBox(height: 24), - - // Parent path field (optional). - - TextFormField( - controller: _parentPathController, - enabled: !_busy, - decoration: const InputDecoration( - labelText: 'Parent Path (optional)', - hintText: 'e.g. myfolder (empty = data directory root)', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - - // Folder name field (required). - - TextFormField( - controller: _folderNameController, - enabled: !_busy, - decoration: const InputDecoration( - labelText: 'Folder Name', - hintText: 'e.g. shared', - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return 'Please enter a folder name'; - } - return null; - }, - ), - const SizedBox(height: 24), - - // Create button. - - ElevatedButton( - onPressed: _busy ? null : _createFolder, - child: const Text('Create folder (with ACL)'), - ), - const SizedBox(height: 12), - - // Delete button. - - ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: Colors.red, - ), - onPressed: _busy ? null : _deleteFolder, - child: const Text('Delete folder'), - ), - ], - ), - ), - ), - ); - } -} diff --git a/example/lib/features/multiple_resource_sharing.dart b/example/lib/features/multiple_resource_sharing.dart deleted file mode 100644 index c5e2ca25..00000000 --- a/example/lib/features/multiple_resource_sharing.dart +++ /dev/null @@ -1,422 +0,0 @@ -/// A widget demonstrating sharing of multiple files. -/// -/// Copyright (C) 2026, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Jess Moore - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart'; -import 'package:solidui/solidui.dart' show GrantPermissionUi; - -// import 'package:solidpodeg/widgets/permission_demo_widgets.dart'; - -/// A widget demonstrating sharing of multiple resources. - -class MultiResourceShareDemo extends StatefulWidget { - const MultiResourceShareDemo({required this.child, super.key}); - - final Widget child; - - @override - State createState() => _MultiResourceShareDemoState(); -} - -class _MultiResourceShareDemoState extends State { - // Status tracking variables. - - // bool _workflowCompleted = false; - // int _currentStep = 1; - String _statusMessage = 'Ready to start granting permission to file list'; - - // Sample files to demonstrate batch permission granting. - // All files will be auto-created for the demo. - - final List _sampleFiles = [ - 'callback-demo/sample-1.ttl', - 'callback-demo/sample-2.ttl', - 'callback-demo/sample-3.ttl', - ]; - - int _currentFileIndex = 0; - - // Reset the demo state. - - void _resetDemo() { - setState(() { - // _workflowCompleted = false; - // _currentStep = 1; - _currentFileIndex = 0; - _statusMessage = 'Ready to start granting permission to file list'; - }); - } - - // Create all demo files automatically. - - Future _ensureDemoFilesExist() async { - try { - for (int i = 0; i < _sampleFiles.length; i++) { - final fileName = _sampleFiles[i]; - - // Create rich demo content with different data for each file - final fileNumber = i + 1; - final demoContent = '''@prefix demo: <#> . -@prefix rdfs: . -@prefix foaf: . - -demo:sampleData$fileNumber a demo:DemoResource ; - rdfs:label "Multiple File Sharing Demo File $fileNumber" ; - demo:created "${DateTime.now().toIso8601String()}" ; - demo:purpose "Demonstrating sharing of multiple files" ; - demo:fileNumber "$fileNumber" ; - demo:description "This is a sameple file for demonstrating sharing multiple files sequentially." ; - foaf:maker "SolidPod Multi Resource Share Demo" . - -demo:exampleData$fileNumber - demo:sampleProperty "Sample value $fileNumber" ; - demo:category "demo-data" ; - demo:testValue ${fileNumber * 100} . -'''; - - // Always create/overwrite the demo file for consistency. - - if (!mounted) return; - - await writePod(fileName, demoContent); - } - } catch (e) { - debugPrint('❌ [MultiShareDemo] Error creating demo files: $e'); - rethrow; // Re-throw to show user the error - } - } - - // Navigate to Grant Permission UI with callback. - - Future _startPermissionWorkflow() async { - // Always ensure demo files exist on first run. - - if (_currentFileIndex == 0) { - setState(() { - _statusMessage = 'Setting up demo files for fresh POD...'; - }); - - try { - await _ensureDemoFilesExist(); - setState(() { - _statusMessage = 'Demo files created! Starting workflow...'; - }); - // Small delay to show success message. - - await Future.delayed(const Duration(milliseconds: 500)); - } catch (e) { - setState(() { - _statusMessage = 'Failed to create demo files: $e'; - }); - } - } - - setState(() { - // _currentStep = 2; - _statusMessage = - 'Navigate to permission screen for file ${_currentFileIndex + 1} of ${_sampleFiles.length}'; - }); - - if (!mounted) return; - - await Navigator.push( - context, - MaterialPageRoute( - fullscreenDialog: true, - builder: (navContext) => Theme( - data: Theme.of(context), - child: Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: AppBar( - title: Text( - 'Multi file sharing', - ), - backgroundColor: Theme.of(context).appBarTheme.backgroundColor, - foregroundColor: Theme.of(context).appBarTheme.foregroundColor, - ), - body: GrantPermissionUi( - resourceNames: _sampleFiles, - title: 'Demo: Grant Permission on Multiple Files', - accessModeList: const ['read'], // Simplified for demo. - recipientTypeList: const ['indi'], // Individual permissions only. - showAppBar: false, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - // ownerWebId: widget.bookOwner, - child: widget.child, - - // // Handle user cancellation/navigation back. - - // onNavigateBack: () { - // if (mounted) { - // setState(() { - // _statusMessage = 'Permission granting cancelled by user'; - // }); - // Navigator.of(navContext).pop(false); - // } - // }, - ), - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[50], - appBar: AppBar( - title: const Text('Multi File Sharing Demo'), - backgroundColor: Colors.blue[700], - foregroundColor: Colors.white, - elevation: 2, - actions: [ - IconButton( - icon: const Icon(Icons.help_outline), - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Troubleshooting'), - content: const SingleChildScrollView( - child: Text( - 'This demo is fully self-contained!\n\n' - '• All demo files are created automatically\n' - '• No manual setup required\n' - '• Works on fresh PODs out of the box\n' - '• Just enter a valid WebID and click "Start Auto-Demo"\n' - '• Try sharing with yourself first for testing\n' - '• Check console (F12) for detailed logs if needed\n\n' - 'Common issues:\n' - '• Verify recipient WebID format ends with #me\n' - '• Ensure recipient has logged into their POD at least once\n' - '• Check browser console for detailed error logs', - style: TextStyle(fontSize: 14, height: 1.4), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ); - }, - tooltip: 'Troubleshooting Help', - ), - ], - ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Demo status section. - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha: 0.1), - spreadRadius: 1, - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Demo Status', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - - // Status message. - - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.green[50], - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: Colors.green[200]!, - ), - ), - child: Row( - children: [ - Icon( - Icons.info, - color: Colors.green[700], - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _statusMessage, - style: TextStyle( - color: Colors.green[800], - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - - // Files to process section. - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha: 0.1), - spreadRadius: 1, - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text( - 'Files to Share', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 8), - Tooltip( - message: - 'Demo creates all files automatically - no setup required!', - child: Icon( - Icons.auto_awesome, - size: 16, - color: Colors.green[600], - ), - ), - ], - ), - const SizedBox(height: 12), - ...List.generate(_sampleFiles.length, (index) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: Colors.green[200]!, - ), - ), - child: Row( - children: [ - Expanded( - child: Text( - _sampleFiles[index], - style: TextStyle( - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - ), - ], - ), - ); - }), - ], - ), - ), - - const SizedBox(height: 24), - - // Action buttons section. - - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: _startPermissionWorkflow, - icon: const Icon(Icons.auto_awesome), - label: Text('Start Auto-Demo'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue[600], - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: _resetDemo, - icon: const Icon(Icons.refresh), - label: const Text('Reset Demo'), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - - // Extra padding at bottom to ensure scrolling works properly. - - const SizedBox(height: 40), - ], - ), - ), - ), - ); - } -} diff --git a/example/lib/features/permission_callback_demo.dart b/example/lib/features/permission_callback_demo.dart deleted file mode 100644 index 2baca46f..00000000 --- a/example/lib/features/permission_callback_demo.dart +++ /dev/null @@ -1,553 +0,0 @@ -/// A demonstration widget showcasing the onPermissionGranted callback functionality. -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Ashley Tang - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart'; -import 'package:solidui/solidui.dart' show GrantPermissionUi; - -import 'package:solidpodeg/widgets/permission_demo_widgets.dart'; - -/// A widget demonstrating the onPermissionGranted callback functionality. - -class PermissionCallbackDemo extends StatefulWidget { - const PermissionCallbackDemo({required this.child, super.key}); - - final Widget child; - - @override - State createState() => _PermissionCallbackDemoState(); -} - -class _PermissionCallbackDemoState extends State { - // Status tracking variables. - - bool _workflowCompleted = false; - int _currentStep = 1; - String _statusMessage = 'Ready to start permission workflow'; - - // Sample files to demonstrate batch permission granting. - // All files will be auto-created for the demo. - - final List _sampleFiles = [ - 'callback-demo/sample-1.ttl', - 'callback-demo/sample-2.ttl', - 'callback-demo/sample-3.ttl', - ]; - - int _currentFileIndex = 0; - - // Reset the demo state. - - void _resetDemo() { - setState(() { - _workflowCompleted = false; - _currentStep = 1; - _currentFileIndex = 0; - _statusMessage = 'Ready to start permission workflow'; - }); - } - - // Create all demo files automatically. - - Future _ensureDemoFilesExist() async { - try { - for (int i = 0; i < _sampleFiles.length; i++) { - final fileName = _sampleFiles[i]; - - // Create rich demo content with different data for each file - final fileNumber = i + 1; - final demoContent = '''@prefix demo: <#> . -@prefix rdfs: . -@prefix foaf: . - -demo:sampleData$fileNumber a demo:DemoResource ; - rdfs:label "Permission Callback Demo File $fileNumber" ; - demo:created "${DateTime.now().toIso8601String()}" ; - demo:purpose "Demonstrating onPermissionGranted callback functionality" ; - demo:fileNumber "$fileNumber" ; - demo:description "This file demonstrates how callbacks enable automated workflows when sharing multiple files sequentially." ; - foaf:maker "SolidPod Permission Callback Demo" . - -demo:exampleData$fileNumber - demo:sampleProperty "Sample value $fileNumber" ; - demo:category "demo-data" ; - demo:testValue ${fileNumber * 100} . -'''; - - // Always create/overwrite the demo file for consistency. - - if (!mounted) return; - - await writePod(fileName, demoContent); - } - } catch (e) { - debugPrint('❌ [CallbackDemo] Error creating demo files: $e'); - rethrow; // Re-throw to show user the error - } - } - - // Navigate to Grant Permission UI with callback. - - Future _startPermissionWorkflow() async { - // Always ensure demo files exist on first run. - - if (_currentFileIndex == 0) { - setState(() { - _statusMessage = 'Setting up demo files for fresh POD...'; - }); - - try { - await _ensureDemoFilesExist(); - setState(() { - _statusMessage = 'Demo files created! Starting workflow...'; - }); - // Small delay to show success message. - - await Future.delayed(const Duration(milliseconds: 500)); - } catch (e) { - setState(() { - _statusMessage = 'Failed to create demo files: $e'; - }); - } - } - - setState(() { - _currentStep = 2; - _statusMessage = - 'Navigate to permission screen for file ${_currentFileIndex + 1} of ${_sampleFiles.length}'; - }); - - if (!mounted) return; - - await Navigator.push( - context, - MaterialPageRoute( - fullscreenDialog: true, - builder: (navContext) => Theme( - data: Theme.of(context), - child: Scaffold( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - appBar: AppBar( - title: Text( - 'Share "${_sampleFiles[_currentFileIndex]}" (${_currentFileIndex + 1}/${_sampleFiles.length})', - ), - backgroundColor: Theme.of(context).appBarTheme.backgroundColor, - foregroundColor: Theme.of(context).appBarTheme.foregroundColor, - ), - body: GrantPermissionUi( - resourceNames: [_sampleFiles[_currentFileIndex]], - title: 'Demo: Grant Permission with Callback', - accessModeList: const ['read'], // Simplified for demo. - recipientTypeList: const ['indi'], // Individual permissions only. - showAppBar: false, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - child: widget.child, - - // 🎯 Key callback being demonstrated. - - onPermissionGranted: () async { - // This callback is triggered when permissions are successfully granted. - - if (mounted) { - // Update our state to reflect success. - - setState(() { - _currentStep = 3; - _statusMessage = - 'Permission granted successfully! Processing next file...'; - }); - - // Navigate back from the permission screen. - - Navigator.of(navContext).pop(true); - - // Continue with the next file in our workflow. - - await _continueWorkflow(); - } - }, - - // Handle user cancellation/navigation back. - - onNavigateBack: () { - if (mounted) { - setState(() { - _statusMessage = 'Permission granting cancelled by user'; - }); - Navigator.of(navContext).pop(false); - } - }, - ), - ), - ), - ), - ); - } - - // Continue workflow after permission is granted. - - Future _continueWorkflow() async { - // Simulate some processing time. - - await Future.delayed(const Duration(milliseconds: 500)); - - if (!mounted) return; - - _currentFileIndex++; - - if (_currentFileIndex < _sampleFiles.length) { - // More files to process. - - setState(() { - _currentStep = 2; - _statusMessage = - 'Moving to next file: ${_sampleFiles[_currentFileIndex]}'; - }); - - // Automatically start next file (in real app, you might want user confirmation). - - await Future.delayed(const Duration(milliseconds: 800)); - if (mounted) { - await _startPermissionWorkflow(); - } - } else { - // All files processed. - - setState(() { - _workflowCompleted = true; - _currentStep = 4; - _statusMessage = 'All files shared successfully! Workflow completed.'; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[50], - appBar: AppBar( - title: const Text('Permission Callback Demo'), - backgroundColor: Colors.blue[700], - foregroundColor: Colors.white, - elevation: 2, - actions: [ - IconButton( - icon: const Icon(Icons.help_outline), - onPressed: () { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Troubleshooting'), - content: const SingleChildScrollView( - child: Text( - 'This demo is fully self-contained!\n\n' - '• All demo files are created automatically\n' - '• No manual setup required\n' - '• Works on fresh PODs out of the box\n' - '• Just enter a valid WebID and click "Start Auto-Demo"\n' - '• Try sharing with yourself first for testing\n' - '• Check console (F12) for detailed logs if needed\n\n' - 'Common issues:\n' - '• Verify recipient WebID format ends with #me\n' - '• Ensure recipient has logged into their POD at least once\n' - '• Check browser console for detailed error logs', - style: TextStyle(fontSize: 14, height: 1.4), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ); - }, - tooltip: 'Troubleshooting Help', - ), - ], - ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header section. - - buildDemoHeaderSection(), - - const SizedBox(height: 24), - - // Current workflow status section. - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha: 0.1), - spreadRadius: 1, - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Workflow Status', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - - // Progress indicator. - - Row( - children: [ - buildStepIndicator(1, 'Start', _currentStep >= 1), - buildStepConnector(_currentStep >= 2), - buildStepIndicator(2, 'Grant', _currentStep >= 2), - buildStepConnector(_currentStep >= 3), - buildStepIndicator(3, 'Process', _currentStep >= 3), - buildStepConnector(_currentStep >= 4), - buildStepIndicator(4, 'Complete', _currentStep >= 4), - ], - ), - - const SizedBox(height: 16), - - // Status message. - - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: _workflowCompleted - ? Colors.green[50] - : Colors.orange[50], - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: _workflowCompleted - ? Colors.green[200]! - : Colors.orange[200]!, - ), - ), - child: Row( - children: [ - Icon( - _workflowCompleted - ? Icons.check_circle - : Icons.info, - color: _workflowCompleted - ? Colors.green[700] - : Colors.orange[700], - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - _statusMessage, - style: TextStyle( - color: _workflowCompleted - ? Colors.green[800] - : Colors.orange[800], - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ], - ), - ), - - const SizedBox(height: 24), - - // Files to process section. - - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.grey.withValues(alpha: 0.1), - spreadRadius: 1, - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text( - 'Files to Share', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(width: 8), - Tooltip( - message: - 'Demo creates all files automatically - no setup required!', - child: Icon( - Icons.auto_awesome, - size: 16, - color: Colors.green[600], - ), - ), - ], - ), - const SizedBox(height: 12), - ...List.generate(_sampleFiles.length, (index) { - final isProcessed = index < _currentFileIndex; - final isCurrent = - index == _currentFileIndex && _currentStep >= 2; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isProcessed - ? Colors.green[50] - : isCurrent - ? Colors.blue[50] - : Colors.grey[50], - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: isProcessed - ? Colors.green[200]! - : isCurrent - ? Colors.blue[200]! - : Colors.grey[200]!, - ), - ), - child: Row( - children: [ - Icon( - isProcessed - ? Icons.check_circle - : isCurrent - ? Icons.radio_button_checked - : Icons.radio_button_unchecked, - color: isProcessed - ? Colors.green[600] - : isCurrent - ? Colors.blue[600] - : Colors.grey[400], - ), - const SizedBox(width: 12), - Expanded( - child: Text( - _sampleFiles[index], - style: TextStyle( - fontWeight: FontWeight.w500, - color: isProcessed || isCurrent - ? Colors.black87 - : Colors.grey[600], - ), - ), - ), - if (isProcessed) - const Text( - 'Shared ✓', - style: TextStyle( - color: Colors.green, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - }), - ], - ), - ), - - const SizedBox(height: 24), - - // Action buttons section. - - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: (_currentStep == 1 || _workflowCompleted) - ? _startPermissionWorkflow - : null, - icon: const Icon(Icons.auto_awesome), - label: Text(_workflowCompleted - ? 'Run Demo Again' - : 'Start Auto-Demo'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue[600], - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: _resetDemo, - icon: const Icon(Icons.refresh), - label: const Text('Reset Demo'), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ], - ), - - // Extra padding at bottom to ensure scrolling works properly. - - const SizedBox(height: 40), - ], - ), - ), - ), - ); - } -} diff --git a/example/lib/features/read_acl_inherited_file.dart b/example/lib/features/read_acl_inherited_file.dart deleted file mode 100644 index 1051822a..00000000 --- a/example/lib/features/read_acl_inherited_file.dart +++ /dev/null @@ -1,146 +0,0 @@ -/// A page to read resources with ACL inheritance -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://www.gnu.org/licenses/gpl-3.0.en.html. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Anushka Vidanage - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart' show readPod; - -// A widget to create a resource with inherited ACL. -// -// The resource will be created inside a parent directory and the ACL of that -// directory will be inherited for that resource. -// -// If resource need to be encrypted, a single encryption key assigned to the -// parent directory will be used for the encryption. -class ReadAclInheritedFile extends StatefulWidget { - const ReadAclInheritedFile({super.key}); - - @override - ReadAclInheritedFileState createState() => ReadAclInheritedFileState(); -} - -class ReadAclInheritedFileState extends State { - final _formKey = GlobalKey(); - - // Controllers for the text fields - final TextEditingController _resourcePathController = TextEditingController(); - - // File content - String _fileContent = ''; - - @override - void dispose() { - // Dispose controllers when widget is removed - _resourcePathController.dispose(); - super.dispose(); - } - - void _submitForm() async { - if (_formKey.currentState!.validate()) { - // Retrieve entered values - String resourcePath = _resourcePathController.text.trim(); - - try { - String fileContent = await readPod(resourcePath); - - setState(() { - _fileContent = fileContent; - }); - } catch (e) { - setState(() { - _fileContent = 'Error reading file: $e'; - }); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Read a resource with ACL inheritance'), - ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - key: _formKey, - child: Column( - children: [ - // Instruction paragraph - const Text( - 'The "Resource Path" field should contain the path ' - 'to the resource itself including the actual resource name ' - 'and extention. An example would be "parentDir/sampleRes.ttl".', - style: TextStyle(fontSize: 16.0, height: 1.5), - ), - const SizedBox(height: 24), - - // Resource path field - TextFormField( - controller: _resourcePathController, - decoration: const InputDecoration( - labelText: 'Resource Path', - border: OutlineInputBorder(), - ), - validator: (value) { - if (value == null || value.isEmpty) { - return 'Please enter a resource path'; - } - return null; - }, - ), - - const SizedBox(height: 24), - - // Submit Button - ElevatedButton( - onPressed: _submitForm, - child: const Text('read resource'), - ), - - const SizedBox(height: 10), - // Display file content if available - if (_fileContent.isNotEmpty) - Container( - margin: const EdgeInsets.only(top: 20), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _fileContent, - style: const TextStyle(fontSize: 16), - ), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/example/lib/features/view_keys.dart b/example/lib/features/view_keys.dart deleted file mode 100644 index 9ca9ccba..00000000 --- a/example/lib/features/view_keys.dart +++ /dev/null @@ -1,208 +0,0 @@ -/// A widget to view private key data in a Solid Pod. -/// -// Time-stamp: -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Anushka Vidanage, Graham Williams - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart' show KeyManager; - -import 'package:solidpodeg/constants/app.dart'; -import 'package:solidpodeg/utils/rdf.dart' show getEncKeyContent; - -/// A widget to show the user all the encryption keys stored in their Solid Pod. - -class ViewKeys extends StatefulWidget { - /// Constructor for the widget. - - const ViewKeys({ - required this.keyInfo, - required this.title, - super.key, - }); - - // Name of the app - // final String appName; - - /// Data of the key file - final String keyInfo; - - // Title of the page - final String title; - - @override - State createState() => _ViewKeysState(); -} - -class _ViewKeysState extends State { - final GlobalKey _scaffoldKey = GlobalKey(); - - /// Decrypted data cache - Map? _decryptedData; - bool _isDecrypting = false; - - /// Decrypt encrypted key values using KeyManager. - /// - /// This function attempts to decrypt the 'prvKey' (private key) value - /// using KeyManager.getPrivateKey(). If decryption fails or - /// the master key is not available, the original encrypted values are returned. - Future> _decryptKeyValues( - Map encFileData) async { - final result = Map.from(encFileData); - - try { - // Get the decrypted private key from KeyManager - final decryptedPrvKey = await KeyManager.getPrivateKey(); - - // Update the result with decrypted value, show truncated for security - if (encFileData.containsKey('prvKey')) { - final displayValue = decryptedPrvKey.length > 80 - ? '${decryptedPrvKey.substring(0, 80)}...' - : decryptedPrvKey; - result['prvKey'] = [ - encFileData['prvKey'][0], - '✓ Decrypted: $displayValue', - ]; - } - } on Exception catch (e) { - debugPrint('ViewKeys: Failed to decrypt private key: $e'); - // Keep original encrypted values, add status note - result['_status'] = ['', '✗ Decryption unavailable']; - } - - return result; - } - - @override - void initState() { - super.initState(); - _loadDecryptedData(); - } - - Future _loadDecryptedData() async { - setState(() { - _isDecrypting = true; - }); - - try { - final encFileData = getEncKeyContent(widget.keyInfo); - final decrypted = await _decryptKeyValues(encFileData); - if (mounted) { - setState(() { - _decryptedData = decrypted; - _isDecrypting = false; - }); - } - } on Exception catch (e) { - debugPrint('ViewKeys: Error loading data: $e'); - if (mounted) { - setState(() { - _isDecrypting = false; - // Fallback to encrypted data on error - _decryptedData = getEncKeyContent(widget.keyInfo); - }); - } - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - key: _scaffoldKey, - appBar: AppBar( - title: Text(widget.title), - backgroundColor: titleBackgroundColor, - ), - body: _buildBody()); - } - - Widget _buildBody() { - if (_isDecrypting) { - return const Center(child: CircularProgressIndicator()); - } - - if (_decryptedData == null) { - return const Center(child: Text('No data available')); - } - - return _loadedScreen(_decryptedData!); - } - - Widget _loadedScreen(Map data) { - final dataRows = data.entries.map((entry) { - return DataRow(cells: [ - DataCell(Text( - entry.key as String, - style: const TextStyle( - fontSize: 12, - ), - )), - DataCell(SizedBox( - width: 600, - child: Text( - entry.value[1] as String, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, - ), - ))), - ]); - }).toList(); - - return Padding( - padding: const EdgeInsets.all(8.0), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - DataTable( - columnSpacing: 30.0, - columns: const [ - DataColumn( - label: Text( - 'Parameter', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ), - DataColumn( - label: Text( - 'Value', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - rows: dataRows), - ], - ), - ), - ); - } -} diff --git a/example/lib/home.dart b/example/lib/home.dart deleted file mode 100644 index d74c89fc..00000000 --- a/example/lib/home.dart +++ /dev/null @@ -1,960 +0,0 @@ -/// A screen to demonstrate various capabilities of solidlogin. -/// -// Time-stamp: -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// -/// Authors: Zheyuan Xu, Anushka Vidanage, Kevin Wang, Dawei Chen, Graham Williams - -// TODO 20240411 gjw EITHER REPAIR ALL CONTEXT ISSUES OR EXPLAIN WHY NOT? - -// ignore_for_file: use_build_context_synchronously - -library; - -import 'package:flutter/material.dart'; - -import 'package:intl/intl.dart'; -import 'package:solidpod/solidpod.dart'; -import 'package:solidui/solidui.dart' - show - changeKeyPopup, - getKeyFromUserIfRequired, - largeGapV, - loginIfRequired, - smallGapV; - -import 'package:solidpodeg/constants/app.dart'; -import 'package:solidpodeg/dialogs/about.dart'; -import 'package:solidpodeg/dialogs/alert.dart'; -import 'package:solidpodeg/dialogs/file_metadata.dart'; -import 'package:solidpodeg/features/absolute_url_demo.dart'; -import 'package:solidpodeg/features/check_file_encryption.dart'; -import 'package:solidpodeg/features/create_acl_inherited_file.dart'; -import 'package:solidpodeg/features/edit_keyvalue.dart'; -import 'package:solidpodeg/features/file_service.dart'; -import 'package:solidpodeg/features/load_test.dart'; -import 'package:solidpodeg/features/read_acl_inherited_file.dart'; -import 'package:solidpodeg/features/view_keys.dart'; -import 'package:solidpodeg/main.dart'; -import 'package:solidpodeg/utils/rdf.dart'; -import 'package:solidpodeg/widgets/home_sections.dart'; - -/// A widget for the demonstration screen of the application. - -class Home extends StatefulWidget { - /// Initialise widget variables. - - const Home({super.key}); - - @override - HomeState createState() => HomeState(); -} - -class HomeState extends State with SingleTickerProviderStateMixin { - String sampleText = ''; - // Step 1: Loading state variable. - - bool _isLoading = false; - - // Indicator for write encrypted/plaintext data - bool _writeEncrypted = true; - - // The current webID - String? _webId; - - @override - void initState() { - super.initState(); - } - - void _resetWebId() { - setState(() { - _webId = null; - }); - } - - Future _showPrivateData(String title) async { - setState(() { - // Begin loading. - - _isLoading = true; - }); - - try { - final fileContent = await readPod( - await getEncKeyPath(), - pathType: PathType.relativeToPod, - ); - - //await Navigator.pushReplacement( // this won't show the file content if POD initialisation has just been performed - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ViewKeys( - keyInfo: fileContent, - title: title, - ), - ), - ); - //} - } on Exception catch (e) { - debugPrint('Exception: $e'); - } finally { - if (mounted) { - setState(() { - // End loading. - - _isLoading = false; - }); - } - } - } - - Future _readWritePrivateData() async { - setState(() { - // Begin loading. - _isLoading = true; - }); - - // final appName = await getAppName(); - - // final fileName = 'test-101.ttl'; - // final fileContent = 'This is for testing writePod.'; - - final fileName = _writeEncrypted ? dataFile : dataFilePlain; - - // final dataDirPath = await getDataDirPath(); - // final filePath = [dataDirPath, fileName].join('/'); - - List<({String key, dynamic value})>? pairs; - - try { - final fileContent = await readPod(fileName); - - pairs = await parseTTLStr(fileContent); - } on Exception catch (e) { - debugPrint('Exception: $e'); - } - - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => KeyValueEdit( - title: 'Basic Key Value Editor', - fileName: fileName, - keyValuePairs: pairs, - encrypted: _writeEncrypted, - child: widget, - ))); - - setState(() { - _isLoading = false; - }); - } - - Future _readMetaData() async { - final fileName = _writeEncrypted ? dataFile : dataFilePlain; - - try { - final fileMetadata = await readResMetadata(fileName); - - final dateFormatter = DateFormat('EEE, dd MMM yyyy HH:mm:ss'); - - showFileMetadataDialog( - context: context, - fileName: fileName, - lastModified: dateFormatter.format(fileMetadata.lastModified), - contentLength: fileMetadata.contentLength.toString(), - contentType: fileMetadata.contentType, - allowdAccess: fileMetadata.wacAllow, - ); - } on Exception catch (e) { - debugPrint('Exception: $e'); - } - } - - // Helper method to demonstrate the security key prompt. - - Future _showSecurityKeyPrompt() async { - // First ensure we are logged in. - - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (loggedIn) { - // Forget the security key to ensure the prompt appears. - - await KeyManager.forgetSecurityKey(); - - // Inform user about what will happen next. - - await alert(context, - 'The security key has been forgotten locally. The next step will show the security key prompt which you would normally see when accessing secured data after logging in.'); - - // Directly show the security key prompt with WebID. - - try { - // This will trigger the security key prompt since we've forgotten the key. - - await getKeyFromUserIfRequired(context, widget); - - // Only show this if the user enters the correct key. - - await alert(context, - 'Your security key was entered correctly and has been saved for this session.'); - } catch (e) { - debugPrint('Error: $e'); - await alert(context, 'Error or cancelled: $e'); - } - } - } - - Future _showFetchNotificationsDialog() async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - if (!loggedIn) return; - - // Make sure the security key is available so the local pair keys can - // be decrypted on demand. - - await getKeyFromUserIfRequired(context, widget); - - List notifications; - try { - notifications = await fetchNotifications(); - } on Exception catch (e) { - debugPrint('fetchNotifications failed: $e'); - if (!context.mounted) return; - await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Fetch Failed'), - content: Text('Could not fetch notifications:\n$e'), - actions: [ - ElevatedButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('OK'), - ), - ], - ), - ); - return; - } - - if (!context.mounted) return; - await showDialog( - context: context, - builder: (ctx) { - return AlertDialog( - title: Text('Notifications (${notifications.length})'), - content: SizedBox( - width: double.maxFinite, - child: notifications.isEmpty - ? const Text('No notifications yet.') - : ListView.separated( - shrinkWrap: true, - itemCount: notifications.length, - separatorBuilder: (_, __) => const Divider(), - itemBuilder: (_, i) { - final n = notifications[i]; - final dt = DateTime.fromMillisecondsSinceEpoch( - n.timestamp, - ); - return ListTile( - dense: true, - title: Text(n.title), - subtitle: Text( - 'From: ${n.senderWebId}\n' - 'At: $dt\n' - 'Priority: ${n.priority}' - '${n.content == null ? '' : '\n${n.content}'}', - ), - ); - }, - ), - ), - actions: [ - ElevatedButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Close'), - ), - ], - ); - }, - ); - } - - Future _showSendNotificationDialog() async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: 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 RecipientNotReadyException catch (e) { - debugPrint('Recipient not ready: $e'); - if (context.mounted) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Recipient Not Ready'), - content: Text( - 'Could not send the notification to ' - '$recipient because their Pod has not been ' - 'upgraded for the new notifications folder.\n\n' - 'Ask the recipient to log in to this app so ' - 'the Update Wizard can finish setting up ' - 'their Pod.\n\n' - 'Details: $e', - ), - 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'), - ), - ], - ); - }, - ); - }, - ); - } - - // A section heading, optionally with a trailing widget (e.g. a toggle) - // pushed to the right-hand edge. - - Widget _sectionHeading(String title, {Widget? trailing}) { - return Row( - children: [ - Text( - title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - if (trailing != null) ...[ - const Spacer(), - trailing, - ], - ], - ); - } - - // Lay a group of buttons out as a wrapping row so that several buttons sit - // side by side rather than one per line. - - Widget _buttonRow(List buttons) { - return Wrap( - spacing: 10, - runSpacing: 8, - children: buttons, - ); - } - - Widget _build(BuildContext context, String title) { - // Build the widget. - - // Include a timestamp on the screen. - - final dateStr = DateFormat('HH:mm:ss dd MMMM yyyy').format(DateTime.now()); - - // Some handy widgets that will be displyed. These are defined here to - // reduce the complexity of the code below. - - final about = IconButton( - icon: const Icon( - Icons.info, - color: Colors.purple, - ), - onPressed: () async { - await aboutDialog(context); - }, - tooltip: 'Popup a window about the app.', - ); - - final date = Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Date: $dateStr', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - ), - ), - ], - ); - - final webid = Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _webId == null ? 'WebID: Not Logged In' : 'WebID: $_webId', - style: TextStyle( - color: _webId == null ? Colors.red : Colors.green, - fontSize: 15, - fontWeight: FontWeight.bold, - ), - ), - ], - ); - - final fileDemoButton = ElevatedButton( - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - if (loggedIn) { - final webId = await getWebId(); - setState(() { - _webId = webId; - }); - - await getKeyFromUserIfRequired(context, widget); - - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - FileService(webId: webId!, child: widget), - ), - ); - } - } - }, - child: const Text('Upload/Download Large File')); - - final inheritanceDemoButton = ElevatedButton( - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - if (loggedIn) { - final webId = await getWebId(); - setState(() { - _webId = webId; - }); - - await getKeyFromUserIfRequired(context, widget); - - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CreateAclInheritedFile())); - } - } - }, - child: const Text('Create Resource with ACL Inheritance')); - - final inheritanceReadButton = ElevatedButton( - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - if (loggedIn) { - final webId = await getWebId(); - setState(() { - _webId = webId; - }); - - await getKeyFromUserIfRequired(context, widget); - - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ReadAclInheritedFile())); - } - } - }, - child: const Text('Read Resource with ACL Inheritance')); - - // Entry point to test read/write/delete using an absolute-URL path - // (PathType.absoluteUrl). Handy for verifying that the core file - // operations work against a full resource URL on the user's own POD. - - final absoluteUrlDemoButton = ElevatedButton( - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - if (loggedIn) { - final webId = await getWebId(); - setState(() { - _webId = webId; - }); - - await getKeyFromUserIfRequired(context, widget); - - if (context.mounted) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const AbsoluteUrlDemo())); - } - } - }, - child: const Text('Read/Write/Delete by Absolute URL')); - - return Scaffold( - appBar: AppBar( - automaticallyImplyLeading: false, - backgroundColor: titleBackgroundColor, - title: Text(title), - actions: [ - about, - ], - ), - body: _isLoading - // If loading show the loading indicator. - ? const Center(child: CircularProgressIndicator()) - // Otherwise we show the screen. - : SingleChildScrollView( - child: Column( - children: [ - smallGapV, - Padding( - padding: const EdgeInsets.all(10.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - date, - webid, - largeGapV, - - // Pod Data File section. The 'Encrypt Data?' toggle is - // shown to the right of the heading. This first section - // gathers all of the core data upload/download - // operations, including the absolute-URL entry point. - - _sectionHeading( - 'Pod Data File', - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Text( - 'Encrypt Data?', - style: TextStyle(fontWeight: FontWeight.bold), - ), - Switch( - value: _writeEncrypted, - onChanged: (val) { - setState(() { - _writeEncrypted = val; - }); - }, - ), - ], - ), - ), - smallGapV, - _buttonRow([ - ElevatedButton( - child: const Text('Read/Write Pod Data File'), - onPressed: () async { - await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: - postLogoutRedirectUrisList, - context: context, - ); - await _readWritePrivateData(); - }, - ), - ElevatedButton( - child: const Text('Read Metadata of Pod Data File'), - onPressed: () async { - await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: - postLogoutRedirectUrisList, - context: context, - ); - await _readMetaData(); - }, - ), - - // SolidPod API: deleteDataFile() - ElevatedButton( - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: - postLogoutRedirectUrisList, - context: context, - ); - if (loggedIn) { - deleteDataFileDialog(dataFile, context); - } - }, - child: const Text('Delete Pod Data File'), - ), - - // SolidPod API: isFileEncrypted() - ElevatedButton( - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: - postLogoutRedirectUrisList, - context: context, - ); - if (loggedIn) { - final webId = await getWebId(); - setState(() { - _webId = webId; - }); - await getKeyFromUserIfRequired(context, widget); - if (context.mounted) { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const CheckFileEncryption(), - ), - ); - } - } - }, - child: const Text('Check File Encryption'), - ), - fileDemoButton, - - // Absolute-URL read/write/delete entry point, kept - // alongside the other core file operations. - - absoluteUrlDemoButton, - ]), - - largeGapV, - - _sectionHeading('ACL Inheritance'), - smallGapV, - _buttonRow([ - inheritanceDemoButton, - inheritanceReadButton, - ]), - - largeGapV, - - _sectionHeading('Notifications'), - smallGapV, - _buttonRow([ - ElevatedButton( - onPressed: _showSendNotificationDialog, - child: const Text('Send Notification'), - ), - ElevatedButton( - onPressed: _showFetchNotificationsDialog, - child: const Text('Fetch Notifications'), - ), - ]), - - largeGapV, - - _sectionHeading('Local Security Key Management'), - smallGapV, - _buttonRow([ - ElevatedButton( - child: const Text('Show Security Key (Encrypted)'), - onPressed: () async { - await _showPrivateData(title); - }, - ), - ElevatedButton( - child: const Text( - 'Show Security Key Prompt (For Demonstration)'), - onPressed: () async { - // Use the dedicated helper method. - - await _showSecurityKeyPrompt(); - }, - ), - ElevatedButton( - onPressed: () { - changeKeyPopup(context, widget); - }, - child: const Text('Change Security Key on Pod'), - ), - ElevatedButton( - child: const Text('Forget Security Key Locally'), - onPressed: () async { - late String msg; - try { - await KeyManager.forgetSecurityKey(); - msg = 'Successfully forgot local security key.'; - _resetWebId(); - } on Exception catch (e) { - msg = 'Failed to forget local security key: $e'; - } - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Notice'), - content: Text(msg), - actions: [ - ElevatedButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('OK')) - ], - ), - ); - }, - ), - ]), - - largeGapV, - - // Load Testing section. Drives the headless Python - // load tester (loadtest/solid_load_test.py) so Pod - // hosting, login and read/write access can be tested - // at scale (up to ~100 concurrent users). - - _sectionHeading('Load Testing'), - smallGapV, - _buttonRow([ - ElevatedButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const LoadTest(), - ), - ); - }, - child: const Text('Run Load Test'), - ), - ]), - ...buildLoginManagementSection( - context, - _resetWebId, - () => const SolidPodEg(), - ), - ...buildPermissionSection( - context, - widget, - () => const Home(), - ), - ...buildSetupWizardSection( - context, - () => const Home(), - ), - ], - ), - ), - ], - ), - ), - ); - } - - Future<({String name, String? webId})> _getInfo() async => - (name: await AppInfo.name, webId: await getWebId()); - - @override - Widget build(BuildContext context) { - return FutureBuilder<({String name, String? webId})>( - future: _getInfo(), - builder: (context, snapshot) { - if (snapshot.hasData) { - final appName = snapshot.data?.name; - final title = 'Demonstrating SolidPod functionalities using ' - '${appName!.isNotEmpty ? appName[0].toUpperCase() + appName.substring(1) : ""}'; - _webId = snapshot.data?.webId; - return _build(context, title); - } else { - return const CircularProgressIndicator(); - } - }, - ); - } -} diff --git a/example/lib/main.dart b/example/lib/main.dart index 73d9899a..d14c5366 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,12 +1,10 @@ -/// A template app to begin a Solid Pod project. -/// -// Time-stamp: -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. +// Rendering the instructions in solidpod/README.md. +// +// Copyright (C) 2026, Software Innovation Institute, ANU. +// +// Licensed under the GNU General Public License, Version 3 (the "License"). +// +// License: https://opensource.org/license/gpl-3-0. // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU General Public License as published by the Free Software @@ -18,125 +16,96 @@ // FOR A PARTICULAR PURPOSE. See the GNU General Public License for more // details. // -// You should have received a copy of the GNU General Public License along withk +// You should have received a copy of the GNU General Public License along with // this program. If not, see . -/// -/// Authors: Graham Williams - -library; - -import 'dart:ui' show PlatformDispatcher; +// +// Authors: Dawei Chen -import 'package:solidpodeg/constants/app.dart'; import 'package:flutter/material.dart'; -import 'package:solidui/solidui.dart' show SolidLogin, InfoButtonStyle; -import 'package:window_manager/window_manager.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:http/http.dart' as http; -import 'package:solidpodeg/home.dart'; -import 'package:solidpodeg/utils/is_desktop.dart'; +void main() { + runApp(const MyApp()); +} -/// Whether [error] is the benign user-cancellation of an AppAuth/OIDC web -/// authentication session — for example when the user dismisses the system -/// sign-in / end-session web sheet, or when the plugin runs a background -/// session check. It does not affect app state and is safe to ignore. +class MyApp extends StatelessWidget { + const MyApp({super.key}); -bool _isBenignAuthCancellation(Object error) { - if (error.runtimeType.toString().contains('UserCancelled')) { - return true; + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Solidpod Readme', + theme: ThemeData(colorScheme: .fromSeed(seedColor: Colors.deepPurple)), + home: const ReadmeViewer(), + ); } - final msg = error.toString(); - return msg.contains('org.openid.appauth') && - (msg.contains('Code=-3') || - msg.contains('WebAuthenticationSession') || - msg.contains('UserCancelled')); } -void main() async { - // Suppress the benign AppAuth/OIDC web-session cancellation that the plugin - // can raise as an uncaught async error (it otherwise surfaces as an - // "Unhandled Exception" in the console even though the operation in progress - // — e.g. granting a permission — has already succeeded). All other errors - // fall through to the previously installed / default handler. - - final previousOnError = PlatformDispatcher.instance.onError; - PlatformDispatcher.instance.onError = (error, stack) { - if (_isBenignAuthCancellation(error)) { - return true; - } - return previousOnError?.call(error, stack) ?? false; - }; - - // Remove [debugPrint] messages from production code. - - debugPrint = (String? message, {int? wrapWidth}) { - null; - }; +class ReadmeViewer extends StatefulWidget { + const ReadmeViewer({super.key}); - // Suport window size and top placement for desktop apps. - - if (isDesktop(PlatformWrapper())) { - WidgetsFlutterBinding.ensureInitialized(); - - await windowManager.ensureInitialized(); - - const windowOptions = WindowOptions( - // Setting [alwaysOnTop] here will ensure the app starts on top of other - // apps on the desktop so that it is visible. We later turn it of as we - // don't want to force it always on top. + @override + State createState() => _ReadmeViewerState(); +} - alwaysOnTop: true, +class _ReadmeViewerState extends State { + late Future _markdownDataFuture; - // The [title] is used for the window manager's window title. + final defaultMessage = + 'solidpod is a low-level library, consider using solidui instead.'; - title: 'SolidPodEg - Demonstrate Private Solid Pod', - ); + final String _url = + 'https://raw.githubusercontent.com/anusii/solidpod/refs/heads/dev/README.md'; - await windowManager.waitUntilReadyToShow(windowOptions, () async { - await windowManager.show(); - await windowManager.focus(); - await windowManager.setAlwaysOnTop(false); - }); + @override + void initState() { + super.initState(); + _markdownDataFuture = fetchReadme(_url); } - // Ready to run the app. - - runApp(const SolidPodEg()); -} - -class SolidPodEg extends StatelessWidget { - const SolidPodEg({super.key}); - - // This widget is the root of our application. + // Network request logic to fetch raw file data + Future fetchReadme(String url) async { + try { + final response = await http.get(Uri.parse(url)); + if (response.statusCode == 200) { + return response.body; // Returns raw Markdown string + } else { + throw Exception('Failed to load Readme: ${response.statusCode}'); + } + } catch (e) { + throw Exception('Network error: $e'); + } + } @override Widget build(BuildContext context) { - return MaterialApp( - title: AppConstants.longName, - home: SolidLogin( - // Images generated using Bing Image Creator from Designer, powered by - // DALL-E3. - - title: AppConstants.longName.toUpperCase(), - appDirectory: 'solidpodeg', - image: AssetImage('assets/images/solidpodeg_image.png'), - logo: AssetImage('assets/images/solidpodeg_logo.png'), - link: - 'https://github.com/anusii/solidpod/blob/main/solidpodeg/README.md', - required: false, - infoButtonStyle: InfoButtonStyle( - tooltip: 'Visit the SolidPodEg documentation.', - ), - clientId: clientIdVal, - // Use the following schemas depending on the platform - // Web: https://anushkavidanage.github.io/solidpod/example/redirect.html - // Mobile/macOS: com.example.solidpodeg://redirect - // Desktop: http://localhost:4400/redirect - // (can use any port as long as it matches with the one in your id document) - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - autoLogin: true, - child: Home(), + return Scaffold( + body: FutureBuilder( + future: _markdownDataFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + '${snapshot.error}', + style: const TextStyle(color: Colors.red), + ), + ), + ); + } + + return Markdown( + data: snapshot.data ?? defaultMessage, + selectable: true, + ); + }, ), ); } diff --git a/example/lib/utils/ensure_resource.dart b/example/lib/utils/ensure_resource.dart deleted file mode 100644 index de5acf6d..00000000 --- a/example/lib/utils/ensure_resource.dart +++ /dev/null @@ -1,154 +0,0 @@ -/// Utility to ensure a Pod resource exists before performing actions on it. -/// -/// Copyright (C) 2026, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Tony Chen - -// ignore_for_file: use_build_context_synchronously - -library; - -import 'package:flutter/material.dart'; - -import 'package:solidpod/solidpod.dart' - show - ResourceStatus, - checkResourceStatus, - createContainer, - filenameToResourceUrl, - writePod; - -import 'package:solidpodeg/dialogs/alert.dart'; - -/// Ensures the resource at [relativePath] exists in the user's Pod. -/// -/// The [relativePath] is interpreted relative to the app's data directory -/// (e.g. `keyvalue/key-value.ttl`). When the resource is missing on the Pod, -/// a new file is created using [defaultContent] (encrypted by default) so -/// downstream actions such as granting permissions do not fail. -/// -/// Returns `true` when the resource is available (already existed or was -/// just created), and `false` otherwise. - -Future ensurePodResourceExists( - BuildContext context, { - required String relativePath, - required String defaultContent, - bool encrypted = true, -}) async { - try { - final fileUrl = await filenameToResourceUrl(fileName: relativePath); - - final status = await checkResourceStatus(fileUrl); - - switch (status) { - case ResourceStatus.exist: - return true; - - case ResourceStatus.notExist: - await _ensureParentContainerWithAcl(relativePath); - await writePod(relativePath, defaultContent, encrypted: encrypted); - - if (context.mounted) { - await alert( - context, - 'The resource "$relativePath" did not exist on your Pod, ' - 'so a new file with placeholder content has been created ' - 'automatically.', - ); - } - return true; - - case ResourceStatus.forbidden: - if (context.mounted) { - await alert( - context, - 'Access to "$relativePath" is forbidden. Please check the ' - 'permissions on your Pod and try again.', - ); - } - return false; - - case ResourceStatus.unknown: - if (context.mounted) { - await alert( - context, - 'Unable to determine whether "$relativePath" exists on your Pod. ' - 'Please try again in a moment.', - ); - } - return false; - } - } on Object catch (e) { - debugPrint('ensurePodResourceExists() failed: $e'); - if (context.mounted) { - await alert( - context, - 'Failed to ensure "$relativePath" exists on your Pod: $e', - ); - } - return false; - } -} - -/// Ensures the parent folder of [relativePath] exists on the Pod with its own -/// `.acl` file. -/// -/// [relativePath] is a file path relative to the app's data directory (e.g. -/// `keyvalue/key-value.ttl`). When the path contains no folder component the -/// file sits directly in the data root and there is nothing to create. -/// -/// If the parent folder already exists it is left untouched (it may already -/// carry an `.acl`); otherwise [createContainer] creates it together with a -/// default `.acl` so the folder can be shared. - -Future _ensureParentContainerWithAcl(String relativePath) async { - final slash = relativePath.lastIndexOf('/'); - if (slash < 0) return; - - final dirPath = relativePath.substring(0, slash); - - // Resolve the directory URL relative to the app data directory (the same - // convention used by writePod and createContainer). filenameToResourceUrl - // prepends `appname/data` and is idempotent for paths that already include - // it. - - final dirUrl = await filenameToResourceUrl( - fileName: dirPath, - isFile: false, - ); - - // Skip creation if the folder already exists; createContainer would fail on - // an existing container, and an existing folder may already have its `.acl`. - - if (await checkResourceStatus(dirUrl, isFile: false) == - ResourceStatus.exist) { - return; - } - - // Split the directory path into its parent path and leaf folder name as - // expected by createContainer. - - final sep = dirPath.lastIndexOf('/'); - final parentPath = sep < 0 ? '' : dirPath.substring(0, sep); - final folderName = sep < 0 ? dirPath : dirPath.substring(sep + 1); - - await createContainer(parentPath, folderName); -} diff --git a/example/lib/utils/is_desktop.dart b/example/lib/utils/is_desktop.dart deleted file mode 100644 index 818e468b..00000000 --- a/example/lib/utils/is_desktop.dart +++ /dev/null @@ -1,55 +0,0 @@ -/// Check if we are running a desktop (and not a browser). -/// -// Time-stamp: -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Graham Williams, Ninad Bhat - -library; - -import 'package:flutter/foundation.dart' show kIsWeb; - -import 'package:universal_io/io.dart' show Platform; - -bool isDesktop(PlatformWrapper platformWrapper) { - /// platformWrapper: PlatformWrapper class is passed in to allow mocking for testing. - /// Returns true if running on Linux, macOS or Windows. - - if (platformWrapper.isWeb) { - return false; - } - - return platformWrapper.isLinux || - platformWrapper.isMacOS || - platformWrapper.isWindows; -} - -// PlatformWrapper coverage is ignored as it is created to test isDesktop() and -// Platform and kIsWeb are not mockable. -// coverage:ignore-start -class PlatformWrapper { - /// Wraps the Platform class to allow mocking for testing. - bool get isLinux => Platform.isLinux; - bool get isMacOS => Platform.isMacOS; - bool get isWeb => kIsWeb; - bool get isWindows => Platform.isWindows; -} -// coverage:ignore-end diff --git a/example/lib/utils/rdf.dart b/example/lib/utils/rdf.dart deleted file mode 100644 index 0c8dedb1..00000000 --- a/example/lib/utils/rdf.dart +++ /dev/null @@ -1,110 +0,0 @@ -/// Common utilities for working on RDF data. -/// -// Time-stamp: -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Dawei Chen -library; - -import 'package:rdflib/rdflib.dart'; -import 'package:solidpod/solidpod.dart' show getWebId; - -// Namespace for keys -const String appTerms = 'https://solidcommunity.au/predicates/terms#'; - -/// Serialise key/value pairs [keyValuePairs] in TTL format where -/// Subject: Web ID -/// Predicate: Key -/// Object: Value - -Future genTTLStr( - List<({String key, dynamic value})> keyValuePairs) async { - assert(keyValuePairs.isNotEmpty); - assert({for (final p in keyValuePairs) p.key}.length == - keyValuePairs.length); // No duplicate keys - final webId = await getWebId(); - assert(webId != null); - final g = Graph(); - final f = URIRef(webId!); - final ns = Namespace(ns: appTerms); - - for (final p in keyValuePairs) { - g.addTripleToGroups(f, ns.withAttr(p.key), p.value); - } - - g.serialize(abbr: 'short'); - - return g.serializedString; -} - -/// Parse TTL string [ttlStr] and returns the key-value pairs from triples where -/// Subject: Web ID -/// Predicate: Key -/// Object: Value - -Future> parseTTLStr(String ttlStr) async { - assert(ttlStr.isNotEmpty); - final g = Graph(); - g.parseTurtle(ttlStr); - final keys = {}; - final pairs = <({String key, dynamic value})>[]; - final webId = await getWebId(); - assert(webId != null); - String extract(String str) => str.contains('#') ? str.split('#')[1] : str; - for (final t in g.triples) { - final sub = t.sub.value as String; - if (sub == webId) { - final pre = extract(t.pre.value as String); - final obj = extract(t.obj.value as String); - assert(!keys.contains(pre)); - keys.add(pre); - pairs.add((key: pre, value: obj)); - } - } - return pairs; -} - -/// Parses enc-key file information and extracts content into a map. -/// -/// This function processes the provided file information, which is expected to be -/// in Turtle (Terse RDF Triple Language) format. It uses a graph-based approach -/// to parse the Turtle data and extract key attributes and their values. - -Map getEncKeyContent(String fileInfo) { - final g = Graph(); - g.parseTurtle(fileInfo); - final fileContentMap = {}; - final fileContentList = []; - for (final t in g.triples) { - final predicate = t.pre.value as String; - if (predicate.contains('#')) { - final subject = t.sub.value; - final attributeName = predicate.split('#')[1]; - final attrVal = t.obj.value.toString(); - if (attributeName != 'type') { - fileContentList.add([subject, attributeName, attrVal]); - } - fileContentMap[attributeName] = [subject, attrVal]; - } - } - - return fileContentMap; -} diff --git a/example/lib/widgets/file_service_sections.dart b/example/lib/widgets/file_service_sections.dart deleted file mode 100644 index 05e4a918..00000000 --- a/example/lib/widgets/file_service_sections.dart +++ /dev/null @@ -1,379 +0,0 @@ -/// Extracted UI sections for the FileService screen. -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Dawei Chen - -library; - -import 'package:flutter/material.dart'; - -/// Builds a progress bar showing operation status. - -Widget buildProgressBar(String message, bool isDone, double percent) { - const smallGapH = SizedBox(width: 10); - const textStyle = TextStyle( - color: Colors.green, - fontWeight: FontWeight.bold, - ); - - final prefix = Text(message, style: textStyle); - final suffix = Text('${(percent * 100).toInt()}%', style: textStyle); - final progress = SizedBox( - width: 300, - height: 10, - child: LinearProgressIndicator( - value: percent, - minHeight: 2, - backgroundColor: Colors.black12, - color: Colors.greenAccent, - ), - ); - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [prefix, smallGapH, progress, smallGapH, suffix], - ); -} - -/// Builds the upload section UI. - -List buildUploadSectionUI({ - required String defaultRemoteFileName, - required String? uploadFile, - required bool uploadDone, - required bool uploadInProgress, - required TextEditingController remoteFolderController, - required TextEditingController keyRefFolderController, - required Widget browseButton, - required Widget uploadButton, -}) { - const smallGapH = SizedBox(width: 10); - const smallGapV = SizedBox(height: 10); - - return [ - Text( - 'Upload a local large file and save it as "$defaultRemoteFileName" in POD', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - smallGapV, - Table( - columnWidths: const { - 0: FixedColumnWidth(450), - }, - children: [ - TableRow( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - uploadFile ?? - 'Click the Browse button to choose a local file', - style: TextStyle( - color: uploadFile == null ? Colors.red : Colors.blue, - fontStyle: FontStyle.italic, - fontSize: 16, - ), - ), - smallGapH, - if (uploadDone) const Icon(Icons.done, color: Colors.green), - ], - ), - ], - ), - TableRow( - children: [ - TextFormField( - controller: remoteFolderController, - enabled: !(uploadInProgress || uploadDone), - decoration: const InputDecoration( - hintText: '(Optional) save to folder in POD, e.g. dir1/dir2/', - hintStyle: TextStyle( - color: Colors.brown, - fontStyle: FontStyle.italic, - fontSize: 15, - ), - ), - ), - ], - ), - TableRow(children: [ - TextFormField( - controller: keyRefFolderController, - enabled: !(uploadInProgress || uploadDone), - decoration: const InputDecoration( - hintText: - '(Optional) Inherit encryption key of folder in POD, e.g. dir1/', - hintStyle: TextStyle( - color: Colors.brown, - fontStyle: FontStyle.italic, - fontSize: 15, - ), - ), - ), - ]), - ], - ), - smallGapV, - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - browseButton, - smallGapH, - uploadButton, - ], - ), - ]; -} - -/// Builds the download section UI. - -List buildDownloadSectionUI({ - required String defaultRemoteFileName, - required String? downloadFile, - required bool downloadDone, - required Widget downloadButton, -}) { - const smallGapH = SizedBox(width: 10); - const smallGapV = SizedBox(height: 10); - - return [ - Text( - 'Download the "$defaultRemoteFileName" from POD', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - smallGapV, - if (downloadFile != null) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Save file'), - smallGapH, - Text( - downloadFile, - style: const TextStyle(color: Colors.blue), - ), - smallGapH, - if (downloadDone) const Icon(Icons.done, color: Colors.green), - ], - ), - smallGapV, - downloadButton, - ]; -} - -/// Builds the delete section UI. - -List buildDeleteSectionUI({ - required String defaultRemoteFileName, - required bool deleteInProgress, - required bool deleteDone, - required Widget deleteButton, -}) { - const smallGapH = SizedBox(width: 10); - const smallGapV = SizedBox(height: 10); - - return [ - Text( - 'Delete the "$defaultRemoteFileName" from POD', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - smallGapV, - if (deleteInProgress || deleteDone) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Delete remote file'), - smallGapH, - Text( - defaultRemoteFileName, - style: const TextStyle(color: Colors.blue), - ), - smallGapH, - if (deleteDone) const Icon(Icons.done, color: Colors.green), - ], - ), - smallGapV, - deleteButton, - ]; -} - -/// Builds the upload-to-external-POD section UI. - -List buildUploadSharedSectionUI({ - required String? uploadSharedFile, - required bool uploadSharedDone, - required bool uploadSharedInProgress, - required TextEditingController uploadSharedUrlController, - required TextEditingController uploadSharedKeyRefController, - required Widget browseSharedButton, - required Widget uploadSharedButton, -}) { - const smallGapH = SizedBox(width: 10); - const smallGapV = SizedBox(height: 10); - - return [ - const Text( - 'Upload a local large file to an external POD', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - smallGapV, - Table( - columnWidths: const { - 0: FixedColumnWidth(550), - }, - children: [ - TableRow( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - uploadSharedFile ?? - 'Click the Browse button to choose a local file', - style: TextStyle( - color: uploadSharedFile == null ? Colors.red : Colors.blue, - fontStyle: FontStyle.italic, - fontSize: 16, - ), - ), - smallGapH, - if (uploadSharedDone) - const Icon(Icons.done, color: Colors.green), - ], - ), - ], - ), - TableRow( - children: [ - TextFormField( - controller: uploadSharedUrlController, - enabled: !(uploadSharedInProgress || uploadSharedDone), - decoration: const InputDecoration( - hintText: - 'Destination URL, e.g. https://SERVER/POD_NAME/APP_NAME/data/FILE_PATH', - hintStyle: TextStyle( - color: Colors.brown, - fontStyle: FontStyle.italic, - fontSize: 15, - ), - ), - ), - ], - ), - TableRow( - children: [ - TextFormField( - controller: uploadSharedKeyRefController, - enabled: !(uploadSharedInProgress || uploadSharedDone), - decoration: const InputDecoration( - hintText: - '(Optional) Inherit encryption key of shared folder, e.g. dir1/', - hintStyle: TextStyle( - color: Colors.brown, - fontStyle: FontStyle.italic, - fontSize: 15, - ), - ), - ), - ], - ), - ], - ), - smallGapV, - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - browseSharedButton, - smallGapH, - uploadSharedButton, - ], - ), - ]; -} - -/// Builds the download shared file section UI. - -List buildDownloadSharedSectionUI({ - required String? downloadSharedFile, - required bool downloadSharedDone, - required bool downloadSharedInProgress, - required TextEditingController sharedUrlController, - required Widget downloadSharedButton, -}) { - const smallGapH = SizedBox(width: 10); - const smallGapV = SizedBox(height: 10); - - return [ - const Text( - 'Download a shared large file from an external POD', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - smallGapV, - SizedBox( - width: 550, - child: TextFormField( - controller: sharedUrlController, - enabled: !(downloadSharedInProgress || downloadSharedDone), - decoration: const InputDecoration( - hintText: 'URL of shared large file in external POD', - hintStyle: TextStyle( - color: Colors.brown, - fontStyle: FontStyle.italic, - fontSize: 15, - ), - ), - ), - ), - smallGapV, - if (downloadSharedFile != null) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('Save file'), - smallGapH, - Text( - downloadSharedFile, - style: const TextStyle(color: Colors.blue), - ), - smallGapH, - if (downloadSharedDone) const Icon(Icons.done, color: Colors.green), - ], - ), - smallGapV, - downloadSharedButton, - ]; -} diff --git a/example/lib/widgets/home_sections.dart b/example/lib/widgets/home_sections.dart deleted file mode 100644 index 188cc4e7..00000000 --- a/example/lib/widgets/home_sections.dart +++ /dev/null @@ -1,428 +0,0 @@ -/// Extracted section widgets for the Home screen. -/// -/// Copyright (C) 2024, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Dawei Chen - -// ignore_for_file: use_build_context_synchronously - -library; - -import 'package:flutter/material.dart'; - -import 'package:markdown_tooltip/markdown_tooltip.dart'; -import 'package:solidpod/solidpod.dart'; -import 'package:solidui/solidui.dart' - show - GrantPermissionUi, - InitialSetupScreenBody, - SharedResourcesUi, - getKeyFromUserIfRequired, - loginIfRequired, - logoutPopup, - largeGapV, - smallGapV; - -import 'package:solidpodeg/constants/app.dart'; -import 'package:solidpodeg/features/manage_acl_folder.dart'; -import 'package:solidpodeg/features/permission_callback_demo.dart'; -import 'package:solidpodeg/features/multiple_resource_sharing.dart'; -import 'package:solidpodeg/utils/ensure_resource.dart'; - -// A bold section heading shared by the extracted sections below. - -Widget _sectionHeading(String title) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - ], - ); -} - -// Lay a group of buttons out as a wrapping row so that several buttons sit -// side by side rather than one per line. - -Widget _buttonRow(List buttons) { - return Wrap( - spacing: 10, - runSpacing: 8, - children: buttons, - ); -} - -/// Builds the login management section widgets. - -List buildLoginManagementSection( - BuildContext context, - VoidCallback onResetWebId, - Widget Function() createSolidPodEg, -) { - return [ - largeGapV, - _sectionHeading('Solid Server Login Management'), - smallGapV, - _buttonRow([ - MarkdownTooltip( - message: 'This will remove from our local device\'s memory the ' - 'solid pod login information so that the next time you ' - 'start up the app you will need to login to your solid ' - 'server hosting your pod.', - child: ElevatedButton( - child: const Text('Forget Remote Solid Server Login'), - onPressed: () async { - final deleteRes = await deleteLogIn(); - - var deleteMsg = ''; - - if (deleteRes) { - deleteMsg = 'Successfully forgot remote solid server login info'; - } else { - deleteMsg = 'Failed to forget login info. Try again in a while'; - } - - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Notice'), - content: Text(deleteMsg), - actions: [ - ElevatedButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('OK')) - ], - ), - ); - - onResetWebId(); - }, - ), - ), - MarkdownTooltip( - message: 'This will send a request through the browser to the ' - 'remote solid server to log you out of your Pod.', - child: ElevatedButton( - onPressed: () async { - await logoutPopup(context, createSolidPodEg()); - }, - child: const Text('Logout From Remote Solid Server'), - ), - ), - ]), - ]; -} - -/// Builds the permission management and external resources section widgets. - -List buildPermissionSection( - BuildContext context, - Widget currentWidget, - Widget Function() createHomeWidget, -) { - return [ - largeGapV, - _sectionHeading('Resource Permission Management'), - smallGapV, - _buttonRow([ - ElevatedButton( - child: const Text( - 'Add/Delete Permissions from a Specific Resource (key-value.ttl)'), - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (loggedIn) { - await getKeyFromUserIfRequired(context, currentWidget); - - // Ensure the target resource exists on the Pod before opening the - // grant permission UI. The button previously failed with a "not - // found" error when keyvalue/key-value.ttl had never been created. - - if (!context.mounted) return; - final ready = await ensurePodResourceExists( - context, - relativePath: dataFile, - defaultContent: createDemoTtlStr('key-value'), - ); - if (!ready) return; - - if (!context.mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => GrantPermissionUi( - backgroundColor: titleBackgroundColor, - resourceNames: [dataFile], - // accessModeList: ['read', 'write'], - // recipientTypeList: ['indi', 'group'], - // isFile: false, - child: createHomeWidget(), - ), - ), - ); - } - }, - ), - ElevatedButton( - child: const Text('Create/Delete a Folder with ACL'), - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (loggedIn) { - await getKeyFromUserIfRequired(context, currentWidget); - - if (!context.mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const ManageAclFolder(), - ), - ); - } - }, - ), - ElevatedButton( - child: const Text('Permission Callback Demo'), - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (loggedIn) { - await getKeyFromUserIfRequired(context, currentWidget); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - PermissionCallbackDemo(child: createHomeWidget()), - ), - ); - } - }, - ), - ElevatedButton( - child: const Text('Add/Delete Permissions from any Resource'), - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (loggedIn) { - await getKeyFromUserIfRequired(context, currentWidget); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => GrantPermissionUi( - backgroundColor: titleBackgroundColor, - child: createHomeWidget(), - ), - ), - ); - } - }, - ), - ElevatedButton( - child: const Text('Share Multiple Specified Resources'), - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (loggedIn) { - await getKeyFromUserIfRequired(context, currentWidget); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - MultiResourceShareDemo(child: createHomeWidget()), - ), - ); - } - }, - ), - ]), - largeGapV, - _sectionHeading('Manage External Resources with Access'), - smallGapV, - _buttonRow([ - ElevatedButton( - child: const Text( - 'View specific resource (key-value.ttl) your WebID has access to'), - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (loggedIn) { - await getKeyFromUserIfRequired(context, currentWidget); - - // Ensure the target resource exists on the Pod before opening the - // shared resources UI. The button previously failed with a "not - // found" error when keyvalue/key-value.ttl had never been created. - - if (!context.mounted) return; - final ready = await ensurePodResourceExists( - context, - relativePath: dataFile, - defaultContent: createDemoTtlStr('key-value'), - ); - if (!ready) return; - - if (!context.mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SharedResourcesUi( - backgroundColor: titleBackgroundColor, - fileName: 'key-value.ttl', - child: createHomeWidget(), - ), - ), - ); - } - }, - ), - ElevatedButton( - child: const Text('View ALL Resources your WebID has access to'), - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (loggedIn) { - await getKeyFromUserIfRequired(context, currentWidget); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SharedResourcesUi( - backgroundColor: titleBackgroundColor, - child: createHomeWidget(), - ), - ), - ); - } - }, - ), - ]), - smallGapV, - ]; -} - -/// Builds the setup wizard section widgets. - -List buildSetupWizardSection( - BuildContext context, - Widget Function() createHomeWidget, -) { - return [ - largeGapV, - _sectionHeading('Setup Wizard Demo'), - smallGapV, - _buttonRow([ - ElevatedButton( - onPressed: () async { - final loggedIn = await loginIfRequired( - clientId: clientIdVal, - redirectUris: redirectUrisList, - postLogoutRedirectUris: postLogoutRedirectUrisList, - context: context, - ); - - if (!loggedIn) { - debugPrint('Please login to run the demo'); - return; - } - - final webId = await getWebId(); - if (webId == null) { - debugPrint('web ID is not available'); - return; - } - - final sampleDirUrl = await getDirUrl([ - await getDataDirPath(), - 'setup_wizard_demo', - ].join('/')); - final sampleFileName = 'setup_wizard_demo.ttl'; - final sampleFileUrl = await getFileUrl([ - await getDataDirPath(), - 'sampleFileName', - ].join('/')); - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => Scaffold( - body: SafeArea( - child: InitialSetupScreenBody( - resNeedToCreate: { - 'folders': [sampleDirUrl], - 'files': [sampleFileUrl], - 'fileNames': [sampleFileName], - }, - child: createHomeWidget(), - ), - ), - ), - ), - ); - }, - child: const Text('Show Solid Pod Setup Wizard (Using Real Component)'), - ), - ]), - smallGapV, - ]; -} diff --git a/example/lib/widgets/permission_demo_widgets.dart b/example/lib/widgets/permission_demo_widgets.dart deleted file mode 100644 index 517b549d..00000000 --- a/example/lib/widgets/permission_demo_widgets.dart +++ /dev/null @@ -1,113 +0,0 @@ -/// Extracted widgets for the Permission Callback Demo screen. -/// -/// Copyright (C) 2025, Software Innovation Institute, ANU. -/// -/// Licensed under the GNU General Public License, Version 3 (the "License"). -/// -/// License: https://opensource.org/license/gpl-3-0. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . -/// -/// Authors: Dawei Chen - -library; - -import 'package:flutter/material.dart'; - -/// Builds a step indicator for the workflow progress display. - -Widget buildStepIndicator(int step, String label, bool isActive) { - return Column( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: isActive ? Colors.blue[600] : Colors.grey[300], - shape: BoxShape.circle, - ), - child: Center( - child: Text( - step.toString(), - style: TextStyle( - color: isActive ? Colors.white : Colors.grey[600], - fontWeight: FontWeight.bold, - fontSize: 14, - ), - ), - ), - ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - fontSize: 12, - color: isActive ? Colors.blue[600] : Colors.grey[600], - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - ), - ), - ], - ); -} - -/// Builds a connector line between step indicators. - -Widget buildStepConnector(bool isActive) { - return Container( - width: 24, - height: 2, - margin: const EdgeInsets.only(bottom: 20), - color: isActive ? Colors.blue[600] : Colors.grey[300], - ); -} - -/// Builds the header section explaining the demo purpose. - -Widget buildDemoHeaderSection() { - return Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.blue[50], - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.blue[200]!), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.lightbulb_outline, color: Colors.blue[700], size: 28), - const SizedBox(width: 12), - const Text( - 'Why Use onPermissionGranted Callback?', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.black87, - ), - ), - ], - ), - const SizedBox(height: 12), - const Text( - 'The onPermissionGranted callback allows your app to automatically ' - 'continue workflows after users grant permissions. This demo creates ' - 'sample files automatically and shows how to share multiple files ' - 'sequentially without manual navigation.', - style: TextStyle(fontSize: 16, height: 1.4), - ), - ], - ), - ); -} diff --git a/example/loadtest/.gitignore b/example/loadtest/.gitignore deleted file mode 100644 index 4de7955f..00000000 --- a/example/loadtest/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Local virtualenv for the load tester (created per README.md). -.venv/ -__pycache__/ diff --git a/example/loadtest/README.md b/example/loadtest/README.md deleted file mode 100644 index 4586f9ee..00000000 --- a/example/loadtest/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Solid load tester - -A headless stress tester for a Community Solid Server (CSS v7+), used to verify -that Pod hosting, login and read/write access hold up at scale (up to ~100 -concurrent users). - -For each simulated user it runs the full lifecycle against the target server: - -1. **Create an account** — CSS account JSON API (`/.account/`). -2. **Create a Pod**. -3. **Change password** *(optional)* — exercises the change-password API. -4. **Issue client credentials**. -5. **Log in** — OAuth `client_credentials` grant with a DPoP-bound token. -6. **Create a security key** — a per-user passphrase from which an AES-256 key - is derived. -7. **Write resources** — authenticated `PUT`, encrypted by default. -8. **Read resources back** — authenticated `GET`, decrypted and verified. - -The account-management flow mirrors solidpod's `css_account_api.dart`. The -read/write flow uses CSS client credentials + DPoP because solidpod/solidui only -support interactive (browser) OIDC login, which does not scale to a hundred -automated users. - -> The "security key" here plays the same role as solidpod's master key, but the -> on-Pod encryption format is intentionally simpler than solidpod's. Encrypted -> resources written by this tester are not meant to be read back by the app. - -## Running it - -The example app can launch this script for you (see the **Load Testing** section -on the demonstrator's home screen). To run it standalone: - -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt - -python3 solid_load_test.py \ - --server https://solid.dev.empwr.au \ - --users 100 \ - --concurrency 20 \ - --resources 3 -``` - -### Useful options - -| Option | Default | Meaning | -| -------------------- | -------------------------- | ----------------------------------------- | -| `--server` | `https://solid.dev.empwr.au` | Server under test. | -| `--users` | `10` | Number of accounts/Pods to create. | -| `--concurrency` | `10` | Maximum users running at once. | -| `--resources` | `3` | Resources each user writes then reads. | -| `--prefix` | `loadtest` | Username/email prefix. | -| `--email-domain` | `example.org` | Email domain for generated accounts. | -| `--password` | `Load-Test-Pw-1!` | Initial account password. | -| `--change-password` | off | Also exercise the change-password API. | -| `--no-encrypt` | off | Write plaintext turtle instead of sealed. | -| `--run-id` | timestamp | Namespaces usernames so reruns differ. | -| `--json` | off | Emit JSON Lines (used by the app). | - -Each run namespaces its accounts as `--`, so repeated runs -do not collide. The accounts and Pods are left in place on the server. - -## Output - -Without `--json`, progress and a final summary are printed as readable text. -With `--json`, every update is a single line of JSON (JSON Lines) — `start`, -`phase`, `user_done`, `progress` and a final `summary` event — which the example -app parses to render live progress. diff --git a/example/loadtest/requirements.txt b/example/loadtest/requirements.txt deleted file mode 100644 index 871ccab1..00000000 --- a/example/loadtest/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Dependencies for solid_load_test.py. -# -# httpx - async HTTP client used to drive the CSS account API and the -# authenticated resource requests concurrently. -# cryptography - EC key generation and ECDSA signing for DPoP proofs, plus -# PBKDF2/AES-GCM for the per-user security key. - -httpx>=0.27 -cryptography>=42.0 diff --git a/example/loadtest/solid_load_test.py b/example/loadtest/solid_load_test.py deleted file mode 100644 index d6c3c776..00000000 --- a/example/loadtest/solid_load_test.py +++ /dev/null @@ -1,921 +0,0 @@ -#!/usr/bin/env python3 -"""Headless load tester for a Community Solid Server (CSS v7+). - -Copyright (C) 2026, Software Innovation Institute, ANU. - -Licensed under the GNU General Public License, Version 3 (the "License"). - -License: https://opensource.org/license/gpl-3-0. - -This program is free software: you can redistribute it and/or modify it under -the terms of the GNU General Public License as published by the Free Software -Foundation, either version 3 of the License, or (at your option) any later -version. - -This program is distributed in the hope that it will be useful, but WITHOUT -ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -details. - -You should have received a copy of the GNU General Public License along with -this program. If not, see . - -Authors: Tony Chen - -This script stress-tests Pod hosting, login and read/write access at scale -(up to ~100 concurrent users). For each simulated user it exercises the full -lifecycle against the target server: - - 1. Create an account (CSS account JSON API) - 2. Create a Pod (CSS account JSON API) - 3. Optionally change password (CSS account JSON API) - 4. Issue client credentials (CSS account JSON API) - 5. Log in for an access token (OAuth client_credentials grant + DPoP) - 6. Create a security key (per-user passphrase + derived AES key) - 7. Write resources (authenticated PUT, optionally encrypted) - 8. Read resources back (authenticated GET, decrypt and verify) - -The account-management flow mirrors solidpod's `css_account_api.dart`. The -authenticated read/write flow uses the CSS client-credentials + DPoP mechanism -because the solidpod/solidui libraries only support interactive (browser) OIDC -login, which does not scale to a hundred automated users. - -The "security key" generated here plays the same role as solidpod's master key: -a per-user passphrase from which a symmetric key is derived and used to encrypt -resource content before it is written, then to decrypt it on read. The on-Pod -key format is deliberately simpler than solidpod's (this tester targets the -server, not the library), so encrypted resources written here are not intended -to be read back by the Flutter app. - -The example app drives this script and parses its output, so when invoked with -`--json` every progress update is emitted as a single line of JSON on stdout -(JSON Lines). Without `--json` the output is human-readable for standalone use. - -Usage (standalone): - - python3 solid_load_test.py --server https://solid.dev.empwr.au \ - --users 100 --concurrency 20 --resources 3 - -Requirements: see requirements.txt (httpx, cryptography). -""" - -from __future__ import annotations - -import argparse -import asyncio -import base64 -import hashlib -import json -import os -import secrets -import sys -import time -from dataclasses import dataclass, field -from typing import Any -from urllib.parse import quote - -import httpx -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import ec, utils as asym_utils -from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC - -# --------------------------------------------------------------------------- -# Output helpers -# -# When the example app launches this script it passes `--json`, so progress is -# emitted as JSON Lines (one JSON object per line). The `_emit()` helper is the -# single choke point for all output so the human-readable and machine-readable -# modes never drift apart. -# --------------------------------------------------------------------------- - -_JSON_MODE = False - - -def _emit(event: str, **fields: Any) -> None: - """Emit a single progress event to stdout. - - In JSON mode each event is a compact one-line JSON object; otherwise a - short human-readable line is printed. stdout is flushed after every event - so the launching app sees progress in real time rather than in a buffered - burst at the end. - """ - - record = {'event': event, **fields} - if _JSON_MODE: - sys.stdout.write(json.dumps(record, separators=(',', ':')) + '\n') - else: - sys.stdout.write(_humanise(record) + '\n') - sys.stdout.flush() - - -def _humanise(record: dict[str, Any]) -> str: - """Render an event as a readable line for standalone (non-JSON) runs.""" - - event = record['event'] - if event == 'start': - return (f"Starting load test: {record['users']} users, " - f"concurrency {record['concurrency']}, server {record['server']}") - if event == 'phase': - status = record['status'].upper() - ms = record.get('ms') - suffix = f" ({ms} ms)" if ms is not None else '' - detail = f" - {record['detail']}" if record.get('detail') else '' - return (f" [user {record['user']:>3}] {record['step']:<17} " - f"{status}{suffix}{detail}") - if event == 'user_done': - outcome = 'OK' if record['ok'] else 'FAILED' - return (f"[user {record['user']:>3}] {record['email']} -> {outcome} " - f"({record['total_ms']} ms)") - if event == 'progress': - return (f"Progress: {record['completed']}/{record['total']} " - f"(ok {record['ok']}, failed {record['failed']})") - if event == 'summary': - return _humanise_summary(record) - if event == 'error': - return f"ERROR: {record.get('message', '')}" - return json.dumps(record) - - -def _humanise_summary(s: dict[str, Any]) -> str: - lines = [ - '', - '=' * 60, - 'LOAD TEST SUMMARY', - '=' * 60, - f"Server : {s['server']}", - f"Users requested : {s['users']}", - f"Users succeeded : {s['users_ok']}", - f"Users failed : {s['users_failed']}", - f"Resources written : {s['resources_written']}", - f"Resources read back : {s['resources_read']}", - f"Wall-clock time : {s['wall_ms']} ms", - '', - 'Per-step timing (ms): mean / p95 / max [failures]', - ] - for step, st in s['steps'].items(): - if st['count'] == 0 and st['failures'] == 0: - continue - lines.append( - f" {step:<17} {st['mean']:>7} / {st['p95']:>7} / {st['max']:>7}" - f" [{st['failures']}]" - ) - lines.append('=' * 60) - return '\n'.join(lines) - - -# --------------------------------------------------------------------------- -# DPoP (Demonstration of Proof-of-Possession) -# -# CSS issues DPoP-bound access tokens for the client_credentials grant, so every -# token request and every authenticated resource request must carry a freshly -# signed DPoP proof JWT. We sign with a per-user EC P-256 key. PyJWT is avoided -# so the only third-party crypto dependency is `cryptography`. -# --------------------------------------------------------------------------- - - -def _b64url(data: bytes) -> str: - """Base64url-encode without padding (as required by JOSE).""" - - return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii') - - -class DpopKey: - """A per-user EC key pair used to sign DPoP proofs.""" - - def __init__(self) -> None: - self._key = ec.generate_private_key(ec.SECP256R1()) - numbers = self._key.public_key().public_numbers() - # The public JWK is embedded in every proof header; CSS uses its - # thumbprint to bind the issued access token to this key. - self.jwk = { - 'kty': 'EC', - 'crv': 'P-256', - 'x': _b64url(numbers.x.to_bytes(32, 'big')), - 'y': _b64url(numbers.y.to_bytes(32, 'big')), - } - - def proof( - self, - method: str, - url: str, - *, - nonce: str | None = None, - access_token: str | None = None, - ) -> str: - """Build a signed DPoP proof JWT for a single request. - - [nonce] is supplied on retry after the server answers with a - `DPoP-Nonce` header. [access_token] is supplied for resource requests so - the proof carries the `ath` (access-token hash) claim binding it to the - token in use. - """ - - header = {'typ': 'dpop+jwt', 'alg': 'ES256', 'jwk': self.jwk} - payload: dict[str, Any] = { - 'htu': url, - 'htm': method, - 'jti': _b64url(secrets.token_bytes(16)), - 'iat': int(time.time()), - } - if nonce is not None: - payload['nonce'] = nonce - if access_token is not None: - digest = hashlib.sha256(access_token.encode('ascii')).digest() - payload['ath'] = _b64url(digest) - - signing_input = ( - _b64url(json.dumps(header, separators=(',', ':')).encode()) - + '.' - + _b64url(json.dumps(payload, separators=(',', ':')).encode()) - ) - der_sig = self._key.sign( - signing_input.encode('ascii'), ec.ECDSA(hashes.SHA256())) - # JOSE wants the raw r||s concatenation, not the ASN.1/DER form that - # `cryptography` returns. - r, s = asym_utils.decode_dss_signature(der_sig) - raw_sig = r.to_bytes(32, 'big') + s.to_bytes(32, 'big') - return signing_input + '.' + _b64url(raw_sig) - - -# --------------------------------------------------------------------------- -# Per-user security key + encryption -# -# Mirrors the intent of solidpod's master key: a random passphrase is generated -# for the user, an AES-256 key is derived from it via PBKDF2, and resource -# content is sealed with AES-GCM before upload then opened again on read. -# --------------------------------------------------------------------------- - - -@dataclass -class SecurityKey: - """A generated security key and the symmetric key derived from it.""" - - passphrase: str - salt: bytes - aes_key: bytes - - @classmethod - def generate(cls) -> 'SecurityKey': - passphrase = secrets.token_urlsafe(24) - salt = secrets.token_bytes(16) - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100_000) - aes_key = kdf.derive(passphrase.encode('utf-8')) - return cls(passphrase=passphrase, salt=salt, aes_key=aes_key) - - def encrypt(self, plaintext: str) -> bytes: - """Seal [plaintext] as nonce || ciphertext for upload.""" - - nonce = secrets.token_bytes(12) - sealed = AESGCM(self.aes_key).encrypt( - nonce, plaintext.encode('utf-8'), None) - return nonce + sealed - - def decrypt(self, blob: bytes) -> str: - """Open content sealed by [encrypt].""" - - nonce, sealed = blob[:12], blob[12:] - opened = AESGCM(self.aes_key).decrypt(nonce, sealed, None) - return opened.decode('utf-8') - - -# --------------------------------------------------------------------------- -# Timing + result accounting -# --------------------------------------------------------------------------- - -# The ordered set of steps each simulated user runs through. Kept as a list so -# the summary reports timings in lifecycle order. -STEP_NAMES = [ - 'create_account', - 'create_pod', - 'change_password', - 'client_credentials', - 'login_token', - 'security_key', - 'write', - 'read', -] - - -@dataclass -class UserResult: - """Outcome and per-step timings for a single simulated user.""" - - index: int - email: str - ok: bool = False - total_ms: int = 0 - resources_written: int = 0 - resources_read: int = 0 - # step name -> (milliseconds, failed?) - timings: dict[str, tuple[int, bool]] = field(default_factory=dict) - - -class _Step: - """Context manager that times a lifecycle step and emits its result. - - A raised exception is recorded as a failure for the step, re-emitted as a - `phase` event, then propagated so the caller can abort the rest of that - user's lifecycle. - """ - - def __init__(self, result: UserResult, name: str) -> None: - self.result = result - self.name = name - self._start = 0.0 - - def __enter__(self) -> '_Step': - self._start = time.monotonic() - return self - - def __exit__(self, exc_type, exc, tb) -> bool: - ms = int((time.monotonic() - self._start) * 1000) - failed = exc_type is not None - self.result.timings[self.name] = (ms, failed) - _emit( - 'phase', - user=self.result.index, - step=self.name, - status='error' if failed else 'ok', - ms=ms, - detail=str(exc) if failed else None, - ) - # Do not suppress the exception; the caller handles user-level abort. - return False - - -# --------------------------------------------------------------------------- -# CSS account JSON API + authenticated resource access -# --------------------------------------------------------------------------- - -_GET_HEADERS = {'Accept': 'application/json'} -_POST_HEADERS = {'Accept': 'application/json', 'Content-Type': 'application/json'} - - -class CssClient: - """A thin async client over one CSS server for a single simulated user.""" - - def __init__(self, client: httpx.AsyncClient, server: str) -> None: - self._http = client - self._server = server.rstrip('/') - self._account_index = f'{self._server}/.account/' - self.dpop = DpopKey() - self.access_token: str | None = None - self._dpop_nonce: str | None = None - - # -- Account management (mirrors css_account_api.dart) ------------------ - - async def _controls( - self, token: str | None = None) -> dict[str, Any]: - """Fetch the account index and return its `controls` object. - - Without [token] only the logged-out controls (login, account.create) - are present; with an account token the account-scoped controls (pod, - clientCredentials, password.create) appear as well. - """ - - headers = dict(_GET_HEADERS) - if token is not None: - headers['Authorization'] = f'CSS-Account-Token {token}' - resp = await self._http.get(self._account_index, headers=headers) - if resp.status_code != 200: - raise RuntimeError( - f'account index HTTP {resp.status_code}: {resp.text[:200]}') - return resp.json().get('controls', {}) - - async def create_account(self, email: str, password: str) -> str: - """Create a bare account and register an email/password login. - - Returns the account authorization token used for the remaining - account-scoped calls. - """ - - controls = await self._controls() - create_url = controls.get('account', {}).get('create') - if not create_url: - raise RuntimeError('server does not expose account.create (CSS v7+ required)') - - resp = await self._http.post(create_url, headers=_POST_HEADERS, json={}) - if not 200 <= resp.status_code < 300: - raise RuntimeError( - f'account create HTTP {resp.status_code}: {resp.text[:200]}') - token = resp.json().get('authorization') - if not token: - raise RuntimeError('account create returned no authorization token') - - # Register the email/password login on the new account. - auth_controls = await self._controls(token) - password_create = auth_controls.get('password', {}).get('create') - if not password_create: - raise RuntimeError('server does not expose password.create') - resp = await self._http.post( - password_create, - headers={**_POST_HEADERS, 'Authorization': f'CSS-Account-Token {token}'}, - json={'email': email, 'password': password}, - ) - if resp.status_code in (400, 409): - raise RuntimeError(f'email "{email}" already registered') - if not 200 <= resp.status_code < 300: - raise RuntimeError( - f'password create HTTP {resp.status_code}: {resp.text[:200]}') - return token - - async def create_pod(self, token: str, pod_name: str) -> str: - """Provision a Pod under the account and return its base URL.""" - - controls = await self._controls(token) - pod_url = controls.get('account', {}).get('pod') - if not pod_url: - raise RuntimeError('server does not expose account.pod') - resp = await self._http.post( - pod_url, - headers={**_POST_HEADERS, 'Authorization': f'CSS-Account-Token {token}'}, - json={'name': pod_name}, - ) - if not 200 <= resp.status_code < 300: - raise RuntimeError( - f'pod create HTTP {resp.status_code}: {resp.text[:200]}') - url = resp.json().get('url') or f'{self._server}/{pod_name}/' - return url if url.endswith('/') else url + '/' - - async def change_password( - self, token: str, email: str, old_password: str, new_password: str) -> None: - """Change the account password via the account JSON API.""" - - controls = await self._controls(token) - logins_url = controls.get('password', {}).get('create') - if not logins_url: - raise RuntimeError('server does not expose password login controls') - auth = {'Authorization': f'CSS-Account-Token {token}'} - resp = await self._http.get(logins_url, headers={**_GET_HEADERS, **auth}) - if resp.status_code != 200: - raise RuntimeError( - f'list logins HTTP {resp.status_code}: {resp.text[:200]}') - logins = resp.json().get('passwordLogins', {}) - resource = next( - (v for k, v in logins.items() if k.lower() == email.lower()), None) - if not resource: - raise RuntimeError(f'no password login found for "{email}"') - resp = await self._http.post( - resource, - headers={**_POST_HEADERS, **auth}, - json={'oldPassword': old_password, 'newPassword': new_password}, - ) - if not 200 <= resp.status_code < 300: - raise RuntimeError( - f'change password HTTP {resp.status_code}: {resp.text[:200]}') - - async def issue_client_credentials( - self, token: str, web_id: str, label: str) -> tuple[str, str]: - """Create a client-credentials token and return (id, secret).""" - - controls = await self._controls(token) - cc_url = controls.get('account', {}).get('clientCredentials') - if not cc_url: - raise RuntimeError('server does not expose account.clientCredentials') - resp = await self._http.post( - cc_url, - headers={**_POST_HEADERS, 'Authorization': f'CSS-Account-Token {token}'}, - json={'name': label, 'webId': web_id}, - ) - if not 200 <= resp.status_code < 300: - raise RuntimeError( - f'client credentials HTTP {resp.status_code}: {resp.text[:200]}') - body = resp.json() - return body['id'], body['secret'] - - # -- Login (client_credentials grant + DPoP) --------------------------- - - async def _token_endpoint(self) -> str: - """Discover the OIDC token endpoint from the server metadata.""" - - url = f'{self._server}/.well-known/openid-configuration' - resp = await self._http.get(url, headers=_GET_HEADERS) - if resp.status_code != 200: - raise RuntimeError( - f'openid-configuration HTTP {resp.status_code}') - endpoint = resp.json().get('token_endpoint') - if not endpoint: - raise RuntimeError('server metadata has no token_endpoint') - return endpoint - - async def login(self, client_id: str, client_secret: str) -> None: - """Exchange client credentials for a DPoP-bound access token.""" - - endpoint = await self._token_endpoint() - # CSS expects each credential URL-encoded before forming Basic auth. - basic = base64.b64encode( - f'{quote(client_id)}:{quote(client_secret)}'.encode()).decode() - - async def attempt(nonce: str | None) -> httpx.Response: - headers = { - 'Authorization': f'Basic {basic}', - 'Content-Type': 'application/x-www-form-urlencoded', - 'DPoP': self.dpop.proof('POST', endpoint, nonce=nonce), - } - return await self._http.post( - endpoint, - headers=headers, - content='grant_type=client_credentials&scope=webid', - ) - - resp = await attempt(None) - # The server may demand a server-chosen nonce; retry once with it. - if resp.status_code in (400, 401) and 'DPoP-Nonce' in resp.headers: - resp = await attempt(resp.headers['DPoP-Nonce']) - if resp.status_code != 200: - raise RuntimeError( - f'token HTTP {resp.status_code}: {resp.text[:200]}') - self.access_token = resp.json()['access_token'] - - # -- Authenticated resource access (DPoP) ------------------------------ - - async def _authed_request( - self, method: str, url: str, *, - content: bytes | None = None, - content_type: str | None = None) -> httpx.Response: - """Issue a DPoP-authenticated request, retrying on nonce challenge.""" - - if self.access_token is None: - raise RuntimeError('not logged in') - - async def attempt(nonce: str | None) -> httpx.Response: - headers = { - 'Authorization': f'DPoP {self.access_token}', - 'DPoP': self.dpop.proof( - method, url, nonce=nonce, access_token=self.access_token), - } - if content_type is not None: - headers['Content-Type'] = content_type - return await self._http.request( - method, url, headers=headers, content=content) - - resp = await attempt(self._dpop_nonce) - if resp.status_code == 401 and 'DPoP-Nonce' in resp.headers: - # Cache the nonce so subsequent requests start with it. - self._dpop_nonce = resp.headers['DPoP-Nonce'] - resp = await attempt(self._dpop_nonce) - if 'DPoP-Nonce' in resp.headers: - self._dpop_nonce = resp.headers['DPoP-Nonce'] - return resp - - async def ensure_container(self, url: str) -> None: - """Create a container (a URL ending in `/`) if it does not exist. - - The empwr server does not auto-create intermediate containers, so the - parent container is provisioned explicitly before any resource is - written into it. - """ - - resp = await self._authed_request( - 'PUT', url, - content=b'', - content_type='text/turtle', - ) - # 201 Created or 205/2xx are fine; CSS returns 4xx if it already exists - # in some configurations, which is equally acceptable here. - if not (200 <= resp.status_code < 300 or resp.status_code == 409): - raise RuntimeError( - f'create container HTTP {resp.status_code}: {resp.text[:200]}') - - async def put_resource( - self, url: str, content: bytes, content_type: str) -> None: - resp = await self._authed_request( - 'PUT', url, content=content, content_type=content_type) - if not 200 <= resp.status_code < 300: - raise RuntimeError( - f'write HTTP {resp.status_code}: {resp.text[:200]}') - - async def get_resource(self, url: str) -> bytes: - resp = await self._authed_request('GET', url) - if resp.status_code != 200: - raise RuntimeError( - f'read HTTP {resp.status_code}: {resp.text[:200]}') - return resp.content - - -# --------------------------------------------------------------------------- -# Per-user lifecycle -# --------------------------------------------------------------------------- - - -async def run_user(cfg: 'Config', index: int) -> UserResult: - """Run the full lifecycle for one simulated user. - - Each step is timed independently. A failure aborts the remaining steps for - this user but never the overall run. - - Each user gets its own [httpx.AsyncClient] (and therefore its own cookie - jar). This isolation is essential: CSS sets an account session cookie on - `/.account/` responses, and a shared jar accumulates several of them, which - makes the server fail concurrent account creation with "Multiple results - for ... accountCookie". Account auth itself uses the `CSS-Account-Token` - header, so per-user cookies cost nothing. - """ - - username = f'{cfg.prefix}-{cfg.run_id}-{index:03d}' - email = f'{username}@{cfg.email_domain}' - result = UserResult(index=index, email=email) - started = time.monotonic() - - password = cfg.password - timeout = httpx.Timeout(60.0, connect=30.0) - - try: - async with httpx.AsyncClient( - timeout=timeout, follow_redirects=True) as http: - client = CssClient(http, cfg.server) - with _Step(result, 'create_account'): - token = await client.create_account(email, password) - - with _Step(result, 'create_pod'): - pod_url = await client.create_pod(token, username) - - web_id = f'{pod_url}profile/card#me' - - if cfg.change_password: - with _Step(result, 'change_password'): - new_password = password + '-rotated' - await client.change_password(token, email, password, new_password) - password = new_password - - with _Step(result, 'client_credentials'): - cc_id, cc_secret = await client.issue_client_credentials( - token, web_id, f'loadtest-{cfg.run_id}') - - with _Step(result, 'login_token'): - await client.login(cc_id, cc_secret) - - with _Step(result, 'security_key'): - security_key = SecurityKey.generate() - - # Provision a dedicated container for this run's resources. - container = f'{pod_url}loadtest/' - await client.ensure_container(container) - - with _Step(result, 'write'): - for r in range(cfg.resources): - name = f'resource-{r:03d}' - payload = _resource_turtle(name, email) - if cfg.encrypt: - body = security_key.encrypt(payload) - await client.put_resource( - f'{container}{name}.enc', body, - 'application/octet-stream') - else: - await client.put_resource( - f'{container}{name}.ttl', payload.encode('utf-8'), - 'text/turtle') - result.resources_written += 1 - - with _Step(result, 'read'): - for r in range(cfg.resources): - name = f'resource-{r:03d}' - if cfg.encrypt: - blob = await client.get_resource(f'{container}{name}.enc') - restored = security_key.decrypt(blob) - else: - restored = ( - await client.get_resource(f'{container}{name}.ttl') - ).decode('utf-8') - # Round-trip check: the read content must match what we wrote. - if email not in restored: - raise RuntimeError( - f'read-back of {name} did not match written content') - result.resources_read += 1 - - result.ok = True - except Exception: # noqa: BLE001 - per-user isolation is intentional. - # The failing step has already emitted its `phase` event; the user is - # simply marked as failed and the run continues. - result.ok = False - - result.total_ms = int((time.monotonic() - started) * 1000) - _emit( - 'user_done', - user=index, - email=email, - ok=result.ok, - total_ms=result.total_ms, - resources_written=result.resources_written, - resources_read=result.resources_read, - ) - return result - - -def _resource_turtle(name: str, email: str) -> str: - """Build a small turtle document used as the write payload.""" - - return ( - '@prefix demo: <#> .\n' - '@prefix rdfs: .\n' - '@prefix foaf: .\n\n' - f'demo:{name} a demo:LoadTestResource ;\n' - f' rdfs:label "Load test resource {name}" ;\n' - f' foaf:maker "{email}" ;\n' - f' demo:created "{int(time.time())}" .\n' - ) - - -# --------------------------------------------------------------------------- -# Orchestration -# --------------------------------------------------------------------------- - - -@dataclass -class Config: - server: str - users: int - concurrency: int - resources: int - prefix: str - email_domain: str - password: str - change_password: bool - encrypt: bool - run_id: str - - -async def run(cfg: Config) -> dict[str, Any]: - """Run every user with bounded concurrency and return the summary dict.""" - - _emit( - 'start', - users=cfg.users, - concurrency=cfg.concurrency, - resources=cfg.resources, - server=cfg.server, - run_id=cfg.run_id, - encrypt=cfg.encrypt, - ) - - semaphore = asyncio.Semaphore(cfg.concurrency) - results: list[UserResult] = [] - completed = 0 - ok_count = 0 - - wall_start = time.monotonic() - - # Each user runs under the semaphore with its own HTTP client (created - # inside run_user) so at most `concurrency` users are in flight at once and - # no cookie state is shared between them. - async def guarded(i: int) -> UserResult: - async with semaphore: - return await run_user(cfg, i) - - tasks = [asyncio.create_task(guarded(i)) for i in range(cfg.users)] - for task in asyncio.as_completed(tasks): - res = await task - results.append(res) - completed += 1 - if res.ok: - ok_count += 1 - _emit( - 'progress', - completed=completed, - total=cfg.users, - ok=ok_count, - failed=completed - ok_count, - ) - - wall_ms = int((time.monotonic() - wall_start) * 1000) - summary = _summarise(cfg, results, wall_ms) - _emit('summary', **summary) - return summary - - -def _summarise( - cfg: Config, results: list[UserResult], wall_ms: int) -> dict[str, Any]: - """Aggregate per-user timings into mean/p95/max per step plus totals.""" - - step_stats: dict[str, dict[str, int]] = {} - for step in STEP_NAMES: - durations = [ - ms for r in results - for (s, (ms, failed)) in r.timings.items() - if s == step and not failed - ] - failures = sum( - 1 for r in results - for (s, (_ms, failed)) in r.timings.items() - if s == step and failed - ) - step_stats[step] = { - 'count': len(durations), - 'failures': failures, - 'mean': int(sum(durations) / len(durations)) if durations else 0, - 'p95': _percentile(durations, 95), - 'max': max(durations) if durations else 0, - } - - users_ok = sum(1 for r in results if r.ok) - return { - 'server': cfg.server, - 'run_id': cfg.run_id, - 'users': cfg.users, - 'users_ok': users_ok, - 'users_failed': cfg.users - users_ok, - 'resources_written': sum(r.resources_written for r in results), - 'resources_read': sum(r.resources_read for r in results), - 'wall_ms': wall_ms, - 'steps': step_stats, - } - - -def _percentile(values: list[int], pct: int) -> int: - """Return the [pct]th percentile of [values] (0 if empty).""" - - if not values: - return 0 - ordered = sorted(values) - # Nearest-rank method; adequate for load-test reporting. - rank = max(0, min(len(ordered) - 1, - int(round((pct / 100) * len(ordered) + 0.5)) - 1)) - return ordered[rank] - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - - -def _parse_args(argv: list[str]) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description='Headless load tester for a Community Solid Server.') - parser.add_argument( - '--server', default='https://solid.dev.empwr.au', - help='Base URL of the Solid server to test.') - parser.add_argument( - '--users', type=int, default=10, - help='Number of simulated users to create.') - parser.add_argument( - '--concurrency', type=int, default=10, - help='Maximum number of users running concurrently.') - parser.add_argument( - '--resources', type=int, default=3, - help='Number of resources each user writes then reads back.') - parser.add_argument( - '--prefix', default='loadtest', - help='Username/email prefix for generated accounts.') - parser.add_argument( - '--email-domain', default='example.org', - help='Email domain for generated accounts.') - parser.add_argument( - '--password', default='Load-Test-Pw-1!', - help='Initial password for every generated account.') - parser.add_argument( - '--change-password', action='store_true', - help='Also exercise the change-password API for each user.') - parser.add_argument( - '--no-encrypt', action='store_true', - help='Write plaintext turtle instead of encrypted resources.') - parser.add_argument( - '--run-id', default=None, - help='Identifier appended to usernames so reruns do not collide ' - '(default: a timestamp).') - parser.add_argument( - '--json', action='store_true', - help='Emit progress as JSON Lines (used by the example app).') - return parser.parse_args(argv) - - -def main(argv: list[str]) -> int: - global _JSON_MODE - args = _parse_args(argv) - _JSON_MODE = args.json - - # A timestamp-based run id keeps usernames unique across reruns without - # needing any persistent state. `time.time()` is used (not a fixed seed) so - # each invocation is naturally namespaced. - run_id = args.run_id or str(int(time.time())) - - cfg = Config( - server=args.server.rstrip('/'), - users=max(1, args.users), - concurrency=max(1, args.concurrency), - resources=max(0, args.resources), - prefix=args.prefix, - email_domain=args.email_domain, - password=args.password, - change_password=args.change_password, - encrypt=not args.no_encrypt, - run_id=run_id, - ) - - try: - summary = asyncio.run(run(cfg)) - except KeyboardInterrupt: - _emit('error', message='Interrupted by user') - return 130 - except Exception as exc: # noqa: BLE001 - top-level guard for clean output. - _emit('error', message=str(exc)) - return 1 - - # A non-zero exit status when every user failed makes the script usable in - # CI as well as from the app. - return 0 if summary['users_ok'] > 0 else 2 - - -if __name__ == '__main__': - sys.exit(main(sys.argv[1:])) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index d4635c1f..04e5fbdf 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,35 +1,91 @@ name: solidpodeg -description: 'A SolidPod Demonstrator.' -publish_to: 'none' -version: 0.0.1+1 +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 environment: - sdk: '>=3.4.3 <4.0.0' + sdk: ^3.13.2 +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. dependencies: - editable: ^2.0.0 - file_picker: ^11.0.2 flutter: sdk: flutter - intl: ^0.20.2 - markdown_tooltip: ^0.0.10 - rdflib: ^0.2.12 - solidpod: ^1.0.15 - solidui: ^1.0.32 - universal_io: ^2.3.1 - window_manager: ^0.5.1 - -dependency_overrides: - solidpod: - path: .. + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + http: ^1.6.0 + flutter_markdown_plus: ^1.0.12 dev_dependencies: - dependency_validator: ^5.0.4 + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. flutter_lints: ^6.0.0 +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. uses-material-design: true - config: - enable-swift-package-manager: false - assets: - - assets/images/ + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/example/support/clean.mk b/example/support/clean.mk deleted file mode 100644 index e4ad7e33..00000000 --- a/example/support/clean.mk +++ /dev/null @@ -1,29 +0,0 @@ -######################################################################## -# -# Makefile template for Cleaning -# -# Copyright 2018-2019 (c) Graham.Williams@togaware.com -# -# License: Creative Commons Attribution-ShareAlike 4.0 International. -# -######################################################################## - -define CLEAN_HELP -Cleanup: - - clean - realclean - -endef -export CLEAN_HELP - -help:: - @echo "$$CLEAN_HELP" - -.PHONY: clean -clean:: - find . -type f \( -name "*~" -o -name "*.bak" \) -exec rm {} \; - -.PHONY: realclean -realclean:: clean - diff --git a/example/support/coverage.sh b/example/support/coverage.sh deleted file mode 100644 index 5723a917..00000000 --- a/example/support/coverage.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -cat coverage/lcov.info | - egrep '^(SF|LF|LH)' | - awk '{gsub(/^(SF:|LF:|LH:)/, "", $0); print}' | - awk 'ORS=NR%3?",":"\n"' | - awk -F',' 'BEGIN { linesTotal=0; hitsTotal=0; } - { linesTotal+=$2; hitsTotal+=$3; print $0; } - END { printf "total,%d,%d\n", linesTotal, hitsTotal; }' | - awk 'BEGIN{ print("file,lines,hits") } {print}' | - mlr --csv put '$cover = round(100*$hits/$lines)' | - tee coverage/coverage.csv | - mlr --csv --opprint --barred cat - -cover=$(mlr --csv filter '$file=="total"' then cut -f cover coverage/coverage.csv | tail +2) - -if [ "$cover" -lt 90 ]; then - echo -e "\nThe code coverage is too low: ${cover}%. Expected at least 90%." - exit 1 -fi diff --git a/example/support/flutter.mk b/example/support/flutter.mk deleted file mode 100644 index bb718793..00000000 --- a/example/support/flutter.mk +++ /dev/null @@ -1,411 +0,0 @@ -######################################################################## -# -# Makefile template for Flutter -# -# Copyright 2021 (c) Graham.Williams@togaware.com -# -# License: Creative Commons Attribution-ShareAlike 4.0 International. -# -######################################################################## - -# App version numbers -# Major release -# Minor update -# Trivial update or bug fix - -ifeq ($(VER),) - VER = $(shell egrep '^version:' pubspec.yaml | cut -d' ' -f2) -endif - -define FLUTTER_HELP -flutter: - - android Run with an attached Android device; - chrome Run with the chrome device; - emu Run with the android emulator; - linux Run with the linux device; - qlinux Run with the linux device and debugPrint() turned off; - - prep Prep for PR by running tests, checks, docs. - push Do a git push and bump the build number if there is one. - - docs Run `dart doc` to create documentation. - - import_order Run import order checking. - import_order_fix Run import order fixing. - - pubspec Choose actual/local pubspec using meld. - - fix Run `dart fix --apply`. - format Run `dart format`. - analyze Run `flutter analyze`. - depend Run `dart run dependency_validator`. - ignore Look for usage of ignore directives. - license Look for missing top license in source code. - - test Run `flutter test` for testing. - itest Run `flutter test integration_test` for interation testing. - qtest Run above test with PAUSE=0. - coverage Run with `--coverage`. - coview View the generated html coverage in browser. - - riverpod Setup `pubspec.yaml` to support riverpod. - runner Build the auto generated code as *.g.dart files. - - desktops Set up for all desktop platforms (linux, windows, macos) - - distributions - apk Builds installers/$(APP).apk - tgz Builds installers/$(APP).tar.gz - - publish Publish a package to pub.dev - -Also supported: - - *.itest - *.qtest - -endef -export FLUTTER_HELP - -help:: - @echo "$$FLUTTER_HELP" - -.PHONY: chrome -chrome: - flutter run -d chrome --release --web-port=4400 - -# 20220503 gjw The following fails if the target files already exist - -# just needs to be run once. -# -# dart run build_runner build --delete-conflicting-outputs -# -# List the files that are automatically generated. Then they will get -# built as required. - -# BUILD_RUNNER = \ -# lib/models/synchronise_time.g.dart - -# $(BUILD_RUNNER): -# dart run build_runner build --delete-conflicting-outputs - -pubspec.lock: - flutter pub get - -.PHONY: linux -linux: pubspec.lock $(BUILD_RUNNER) - flutter run --device-id linux - -# Turn off debugPrint() output. - -.PHONY: qlinux -qlinux: pubspec.lock $(BUILD_RUNNER) - flutter run --dart-define DEBUG_PRINT="FALSE" --device-id linux - -.PHONY: macos -macos: $(BUILD_RUNNER) - flutter run --device-id macos - -.PHONY: android -android: $(BUILD_RUNNER) - flutter run --device-id $(shell flutter devices | grep android | tr '•' '|' | tr -s '|' | tr -s ' ' | cut -d'|' -f2 | tr -d ' ') - -.PHONY: emu -emu: - @if [ -n "$(shell flutter devices | grep emulator | cut -d" " -f 6)" ]; then \ - flutter run --device-id $(shell flutter devices | grep emulator | cut -d" " -f 6); \ - else \ - flutter emulators --launch Pixel_3a_API_30; \ - echo "Emulator has been started. Rerun `make emu` to build the app."; \ - fi - -.PHONY: linux_config -linux_config: - flutter config --enable-linux-desktop - -.PHONY: prep -prep: analyze fix import_order_fix format ignore license todo - @echo "ADVISORY: make tests docs" - @echo $(SEPARATOR) - -.PHONY: docs -docs:: - dart doc - chmod -R go+rX doc - -SEPARATOR="------------------------------------------------------------------------" - -.PHONY: pubspec -pubspec: - meld pubspec.yaml.actual pubspec.yaml pubspec.yaml.local - -.PHONY: fix -fix: - @echo "Dart: FIX" - dart fix --apply lib - @echo $(SEPARATOR) - -.PHONY: format -format: - @echo "Dart: FORMAT" - dart format lib/ - @echo $(SEPARATOR) - -# My emacs IDE is starting to add imports of backups automagically! - -.PHONY: bakfix -bakfix: - @echo "Find and fix imports of backups." - find lib -type f -name '*.dart*' -exec sed -i 's/\.dart\.~\([0-9]\)~/\.dart/g' {} + - @echo $(SEPARATOR) - -.PHONY: tests -tests:: test qtest - -.PHONY: analyze -analyze: - @echo "Futter ANALYZE" - -flutter analyze lib -# dart run custom_lint - @echo $(SEPARATOR) - -# dart pub global activate dependency_validator - -.PHONY: depend -depend: - @echo "Review pubspec.yaml dependencies." - -dart run dependency_validator - @echo $(SEPARATOR) - -.PHONY: ignore -ignore: - @echo "Files that override lint checks with IGNORE:\n" - @-if grep -r -n ignore: lib; then exit 1; else exit 0; fi - @echo $(SEPARATOR) - -.PHONY: todo -todo: - @echo "Files that include TODO items to be resolved:\n" - @-if grep -r -n ' TODO ' lib; then exit 1; else exit 0; fi - @echo $(SEPARATOR) - -.PHONY: license -license: - @echo "Files without a LICENSE:\n" - @-find lib -type f -not -name '*~' ! -exec grep -qE '^(/// .*|/// Copyright|/// Licensed)' {} \; -print | xargs printf "\t%s\n" - @echo $(SEPARATOR) - -.PHONY: riverpod -riverpod: - flutter pub add flutter_riverpod - flutter pub add riverpod_annotation - flutter pub add dev:riverpod_generator - flutter pub add dev:build_runner - flutter pub add dev:custom_lint - flutter pub add dev:riverpod_lint - -.PHONY: runner -runner: - dart run build_runner build - -# Support desktop platforms: Linux, MacOS and Windows. Using the -# project name as in the already existant pubspec.yaml ensures the -# project name is a valid name. Otherwise it is obtained from the -# folder name and may not necessarily be a valid flutter project name. - -.PHONY: desktops -desktops: - flutter create --platforms=windows,macos,linux --project-name $(shell grep 'name: ' pubspec.yaml | awk '{print $$2}') . - -######################################################################## -# INTEGRATION TESTING -# -# Run the integration tests for the desktop device (linux, windows, -# macos). Without this explictly specified, if I have my android -# device connected to the computer then the testing defaults to trying -# to install on android. 20230713 gjw - -.PHONY: test -test: - @echo "Unit TEST:" - -flutter test test - @echo $(SEPARATOR) - -# For a specific interactive test we think of it as providing a -# demonstration of the app functionality that we may actually use to -# create a narrated video. A INTERACT of 5 or more is then useful. - -%.itest: - @device_id=$(shell flutter devices | grep -E 'linux|macos|windows' | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|'); \ - if [ -z "$$device_id" ]; then \ - echo "No desktop device found. Please ensure you have the correct desktop platform enabled."; \ - exit 1; \ - fi; \ - flutter test --dart-define=INTERACT=5 --device-id $$device_id integration_test/$*.dart - -# For a run over all tests interactively we INTERACT a little but not as -# much as when running the individual tests. - -.PHONY: itest -itest: - @device_id=$(shell flutter devices | grep -E 'linux|macos|windows' | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|'); \ - if [ -z "$$device_id" ]; then \ - echo "No desktop device found. Please ensure you have the correct desktop platform enabled."; \ - exit 1; \ - fi; \ - for t in integration_test/*.dart; do flutter test --dart-define=INTERACT=2 --device-id $$device_id $$t; done - @echo $(SEPARATOR) - -# For the quick tests we do not INTERACT at all. The aim is to quickly -# test all functionality. - -.PHONY: qtest -qtest: - @device_id=$(shell flutter devices | grep -E 'linux|macos|windows' | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|'); \ - if [ -z "$$device_id" ]; then \ - echo "No desktop device found. Please ensure you have the correct desktop platform enabled."; \ - exit 1; \ - fi; \ - for t in integration_test/*.dart; do \ - echo "========================================"; \ - echo $$t; /bin/echo -n $$t >&2; \ - echo "========================================"; \ - flutter test --dart-define=INTERACT=0 --device-id $$device_id --reporter failures-only $$t 2>/dev/null; \ - if [ "$$?" -eq 0 ]; then /bin/echo ' YES' >&2; else /bin/echo -n ' ...' >&2; \ - echo '****************************************> TRY AGAIN'; \ - flutter test --dart-define=INTERACT=0 --device-id $$device_id --reporter failures-only $$t 2>/dev/null; \ - if [ "$$?" -eq 0 ]; then /bin/echo ' YES' >&2; else /bin/echo ' NO *****' >&2; fi; fi; \ - done - @echo $(SEPARATOR) - -%.qtest: - @device_id=$(shell flutter devices | grep -E 'linux|macos|windows' | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|'); \ - if [ -z "$$device_id" ]; then \ - echo "No desktop device found. Please ensure you have the correct desktop platform enabled."; \ - exit 1; \ - fi; \ - flutter test --dart-define=INTERACT=0 --device-id $$device_id --reporter failures-only integration_test/$*.dart 2>/dev/null - -.PHONY: qtest.all -qtest.all: - @echo $(APP) `egrep '^version: ' pubspec.yaml` - @echo "flutter version:" `flutter --version | head -1 | cut -d ' ' -f 2` - make qtest > qtest_$(shell date +%Y%m%d%H%M%S).txt - -clean:: - rm -f qtest_*.txt - -.PHONY: atest -atest: - @echo "Full integration TEST:" - flutter test --dart-define=INTERACT=0 --verbose --device-id \ - $(shell flutter devices | grep desktop | perl -pe 's|^[^•]*• ([^ ]*) .*|\1|') \ - integration_test - @echo $(SEPARATOR) - -.PHONY: coverage -coverage: - @echo "COVERAGE" - @flutter test --coverage - @echo - @-/bin/bash support/coverage.sh - @echo $(SEPARATOR) - -.PHONY: coview -coview: - @genhtml coverage/lcov.info -o coverage/html - @open coverage/html/index.html - -realclean:: - rm -rf coverage - -# Crate an installer for Linux as a tar.gz archive. - -tgz:: $(APP)-$(VER)-linux-x86_64.tar.gz - -$(APP)-$(VER)-linux-x86_64.tar.gz: clean - mkdir -p installers - rm -rf build/linux/x64/release - flutter build linux --release - tar --transform 's|^build/linux/x64/release/bundle|$(APP)|' -czvf $@ build/linux/x64/release/bundle - cp $@ installers/ - mv $@ installers/$(APP).tar.gz - -apk:: - flutter build apk --release - cp build/app/outputs/flutter-apk/app-release.apk installers/$(APP).apk - cp build/app/outputs/flutter-apk/app-release.apk installers/$(APP)-$(VER).apk - -appbundle: - flutter build appbundle --release - -realclean:: - flutter clean - flutter pub get - -# For the `dev` branch only, update the version sequence number prior -# to a push (relies on the git.mk being loaded after this -# flutter.mk). This is only undertaken through `make push` rather than -# a `git push` in any other way. If -# the pubspec.yaml is not using a build number then do not push to bump -# the build number. - -VERSEQ=$(shell grep '^version: ' pubspec.yaml | cut -d'+' -f2 | awk '{print $$1+1}') - -BRANCH := $(shell git branch --show-current) - -ifeq ($(BRANCH),dev) -push:: - @echo $(SEPARATOR) - perl -pi -e 's|(^version: .*)\+.*|$$1+$(VERSEQ)|' pubspec.yaml - -egrep '^version: .*\+.*' pubspec.yaml && \ - git commit -m "Bump sequence $(VERSEQ)" pubspec.yaml -endif - -.PHONY: publish -publish: - dart pub publish - -# dart pub global activate import_order_lint - -.PHONY: import_order -import_order: - @echo "Dart: CHECK IMPORT ORDER" - dart run custom_lint - @echo $(SEPARATOR) - -.PHONY: import_order_fix -import_order_fix: - @echo "Dart: FIX IMPORT ORDER" - fix_imports --project-name=$(APP) -r lib - @echo $(SEPARATOR) - -### TODO THESE SHOULD BE CHECKED AND CLEANED UP - - -.PHONY: docs -docs:: - rsync -avzh doc/api/ root@solidcommunity.au:/var/www/html/docs/$(APP)/ - -.PHONY: versions -versions: - perl -pi -e 's|applicationVersion = ".*";|applicationVersion = "$(VER)";|' \ - lib/constants/app.dart - -.PHONY: wc -wc: lib/*.dart - @cat $(shell find lib -name '*.dart') \ - | egrep -v '^ */' \ - | egrep -v '^ *$$' \ - | wc -l - -# -# Manage the production install on the remote server. -# - -.PHONY: solidcommunity -solidcommunity: - rsync -avzh ./ solidcommunity.au:projects/$(APP)/ \ - --exclude .dart_tool --exclude build --exclude ios --exclude macos \ - --exclude linux --exclude windows --exclude android - ssh solidcommunity.au '(cd projects/$(APP); flutter upgrade; make prod)' diff --git a/example/support/git.mk b/example/support/git.mk deleted file mode 100644 index b4d4bf92..00000000 --- a/example/support/git.mk +++ /dev/null @@ -1,127 +0,0 @@ -######################################################################## -# -# Makefile template for Version Control - git -# -# Time-stamp: -# -# Copyright 2018-2024 (c) Graham.Williams@togaware.com -# -# License: Creative Commons Attribution-ShareAlike 4.0 International. -# -######################################################################## - -define GIT_HELP -git: - - info Identify the git repository; - status Status listing untracked files; - qstatus A quieter status ignoring untracked files; - - enter Do a git status, fetch, and rebase - exit Do a git status - - push - pull - - fetch Update local repo from remote. - stash Stash changes to allow a rebase. - merge Update local repo with remote updates. - rebase Rebase local repo to include remote in history. - pop Pop the stash. - - main Checkout the main branch; - dev Checkout the dev branch; - log - flog Show the full log; - gdiff - vdiff Show a visual diff using meld. - - upstream Merge from upstrem to local repo. - -endef -export GIT_HELP - -help:: - @echo "$$GIT_HELP" - -info: - @echo "-------------------------------------------------------" - git config --get remote.origin.url - @echo "-------------------------------------------------------" - -status: - @echo "-------------------------------------------------------" - git status - @echo "-------------------------------------------------------" - -qstatus: - @echo "-------------------------------------------------------" - git status --untracked-files=no - @echo "-------------------------------------------------------" - -enter:: status fetch rebase -exit:: status push - -# Use :: to allow push to be augmented in other makefiles. - -push:: - @echo "-------------------------------------------------------" - git push - @echo "-------------------------------------------------------" - -pull: - @echo "-------------------------------------------------------" - git pull --stat - @echo "-------------------------------------------------------" - -fetch: - @echo "-------------------------------------------------------" - git fetch - @echo "-------------------------------------------------------" - -stash: - @echo "-------------------------------------------------------" - git stash - @echo "-------------------------------------------------------" - -rebase: - @echo "-------------------------------------------------------" - git rebase - @echo "-------------------------------------------------------" - -pop: - @echo "-------------------------------------------------------" - git stash pop - @echo "-------------------------------------------------------" - -main: - @echo "-------------------------------------------------------" - git checkout main - @echo "-------------------------------------------------------" - -dev: - @echo "-------------------------------------------------------" - git checkout $(USER)/dev - @echo "-------------------------------------------------------" - -log: - @echo "-------------------------------------------------------" - git --no-pager log --stat --max-count=10 - @echo "-------------------------------------------------------" - -flog: - @echo "-------------------------------------------------------" - git --no-pager log - @echo "-------------------------------------------------------" - -gdiff: - @echo "-------------------------------------------------------" - git --no-pager diff --color - @echo "-------------------------------------------------------" - -vdiff: - git difftool --tool=meld - -upstream: - git fetch upstream - git merge upstream/main diff --git a/example/support/install.mk b/example/support/install.mk deleted file mode 100644 index dafafeae..00000000 --- a/example/support/install.mk +++ /dev/null @@ -1,60 +0,0 @@ -######################################################################## -# -# Makefile template for Installations -# -# Time-stamp: -# -# Copyright (c) Graham.Williams@togaware.com -# -# License: Creative Commons Attribution-ShareAlike 4.0 International. -# -######################################################################## - -# Define PROD and MINE if not already defined. - -PROD ?= $(DEST) -MINE ?= $(DEST:$(APP)=$(USER)) - -# Only allow prod if in main branch. - -BRANCH := $(shell git branch --show-current) - -ifeq ($(BRANCH),main) - PROD ?= $(DEST) -else - PROD ?= $(MINE) -endif - -define INSTALL_HELP -installs: - - prod Install $(APP) into $(PROD) - install Install $(APP) into $(MINE) - -endef -export INSTALL_HELP - -help:: - @echo "$$INSTALL_HELP" - -######################################################################## -# LOCAL TARGETS - -install: $(USER).install - -ifeq ($(BRANCH),main) -prod: $(APP).install -else -prod: $(USER).install -endif - -%.install: - cp web/index.html web/index.html.bak - perl -pi -e 's|^ |' web/index.html - flutter build web - mv web/index.html.bak web/index.html - if [ ! -e $(DEST:$(APP)=$*) ]; then \ - sudo mkdir $(DEST:$(APP)=$*); \ - fi - sudo rsync -azvh build/web/ $(DEST:$(APP)=$*) - sudo chmod -R a+rX $(DEST:$(APP)=$*) diff --git a/example/support/modules.mk b/example/support/modules.mk deleted file mode 100644 index a11b5a53..00000000 --- a/example/support/modules.mk +++ /dev/null @@ -1,68 +0,0 @@ -######################################################################## -# Supported Makefile modules. -# -# Often the support Makefiles will be in the local support folder, or -# else installed in the local user's shares. - -INC_CLEAN ?= $(INC_BASE)/clean.mk -INC_BOOKDOWN ?= $(INC_BASE)/bookdown.mk -INC_R ?= $(INC_BASE)/r.mk -INC_KNITR ?= $(INC_BASE)/knitr.mk -INC_PANDOC ?= $(INC_BASE)/pandoc.mk -INC_GIT ?= $(INC_BASE)/git.mk -INC_AZURE ?= $(INC_BASE)/azure.mk -INC_LATEX ?= $(INC_BASE)/latex.mk -INC_PDF ?= $(INC_BASE)/pdf.mk -INC_DOCKER ?= $(INC_BASE)/docker.mk -INC_FLUTTER ?= $(INC_BASE)/flutter.mk -INC_JEKYLL ?= $(INC_BASE)/jekyll.mk -INC_MLHUB ?= $(INC_BASE)/mlhub.mk -INC_WEBCAM ?= $(INC_BASE)/webcam.mk -INC_INSTALL ?= $(INC_BASE)/install.mk - -ifneq ("$(wildcard $(INC_CLEAN))","") - include $(INC_CLEAN) -endif -ifneq ("$(wildcard $(INC_BOOKDOWN))","") - include $(INC_BOOKDOWN) -endif -ifneq ("$(wildcard $(INC_R))","") - include $(INC_R) -endif -ifneq ("$(wildcard $(INC_KNITR))","") - include $(INC_KNITR) -endif -ifneq ("$(wildcard $(INC_PANDOC))","") - include $(INC_PANDOC) -endif -ifneq ("$(wildcard $(INC_FLUTTER))","") - include $(INC_FLUTTER) -endif -ifneq ("$(wildcard $(INC_GIT))","") - include $(INC_GIT) -endif -ifneq ("$(wildcard $(INC_AZURE))","") - include $(INC_AZURE) -endif -ifneq ("$(wildcard $(INC_LATEX))","") - include $(INC_LATEX) -endif -ifneq ("$(wildcard $(INC_PDF))","") - include $(INC_PDF) -endif -ifneq ("$(wildcard $(INC_DOCKER))","") - include $(INC_DOCKER) -endif -ifneq ("$(wildcard $(INC_JEKYLL))","") - include $(INC_JEKYLL) -endif -ifneq ("$(wildcard $(INC_MLHUB))","") - include $(INC_MLHUB) -endif -ifneq ("$(wildcard $(INC_WEBCAM))","") - include $(INC_WEBCAM) -endif -ifneq ("$(wildcard $(INC_INSTALL))","") - include $(INC_INSTALL) -endif - diff --git a/pubspec.yaml b/pubspec.yaml index ea92be34..4d503bcd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: solidpod description: Support access to private data from PODs on Solid servers. -version: 1.0.18 +version: 1.0.19 homepage: https://github.com/anusii/solidpod environment: @@ -19,31 +19,31 @@ dependencies: flutter: sdk: flutter - archive: ^4.0.8 + archive: ^4.0.9 crypto: ^3.0.7 cryptography_plus: ^3.0.0 encrypter_plus: ^5.1.0 fast_rsa: ^3.8.6 - flutter_secure_storage: ^10.0.0 + flutter_secure_storage: ^10.3.1 http: ^1.6.0 - intl: ^0.20.2 + intl: ^0.20.3 jwt_decoder: ^2.0.1 - mime: ^2.0.0 + mime: ^2.1.0 oidc: ^0.14.0 - package_info_plus: ^9.0.0 + package_info_plus: ^10.2.1 path: ^1.9.1 - petitparser: ^6.1.0 + petitparser: ^7.0.2 pointycastle: ^4.0.0 - rdflib: ^0.2.12 + rdflib: ^0.2.13 solid_auth: ^1.0.5 universal_io: ^2.3.1 dev_dependencies: - build_runner: ^2.10.5 + build_runner: ^2.15.1 custom_lint: ^0.8.1 - dependency_validator: ^5.0.4 + dependency_validator: ^5.0.6 flutter_lints: ^6.0.0 - import_order_lint: ^0.2.2 + import_order_lint: ^0.2.3 flutter: config: