Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,27 @@ fi

echo "$result"
printf "\e[32;1m%s\e[0m\n" 'Finished running dart format.'

# Get the commit message.

COMMIT_MSG=$(git log -1 --pretty=%B)

# 20260123 gjw Check if the commit message starts with 'Bump version'
# If it does then run `make versions` to update the version in any
# files that require updating, based on the version from pubspec.yaml.
# Then add any updated files to the commit. I add specific files
# rather than using `.` just to avoid possibly adding things I was not
# planning to. This way we always update, for example, the snap
# version when we 'Bump version', avoiding missing this step, so the
# installer builds that 'Bump version' triggers will get the correct
# versions.

if [[ $COMMIT_MSG == Bump\ version* ]]; then
printf "\e[33;1m%s\e[0m\n" 'Update versions.'

make versions
git add snap/snapcraft.yaml

printf "\e[32;1m%s\e[0m\n" 'Finished updating versions.'

fi
7 changes: 0 additions & 7 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,6 @@
#
# If this script exits with a non-zero status nothing will be pushed.

PVER=$(egrep '^version:' pubspec.yaml | cut -d' ' -f2 | cut -d'+' -f1)

if [ -d snap ]; then
printf "\e[33;1m%s\e[0m\n" 'Update snap version.'
perl -pi -e "s|^version:.*|version: ${PVER}|" snap/snapcraft.yaml;
fi

# Flutter Analyze

printf "\e[33;1m%s\e[0m\n" 'Running flutter analyze.'
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ on:
types: [opened, reopened, synchronize]

env:
FLUTTER_VERSION: '3.38.5'
FLUTTER_VERSION: '3.38.9'

jobs:

Expand Down
12 changes: 12 additions & 0 deletions example/lib/app_scaffold.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import 'package:solidui/solidui.dart';

import 'constants/app.dart';
import 'home.dart';
import 'screens/all_pod_files_page.dart';
import 'screens/sample_page.dart';

final _scaffoldController = SolidScaffoldController();
Expand Down Expand Up @@ -73,6 +74,17 @@ class AppScaffold extends StatelessWidget {
''',
child: SolidFile(),
),
SolidMenuItem(
icon: Icons.storage,
title: 'All POD Files',
tooltip: '''

**All POD Files:** Tap here to browse all folders on your POD
from the root.

''',
child: AllPodFilesPage(),
),
],

// APP BAR.
Expand Down
49 changes: 49 additions & 0 deletions example/lib/screens/all_pod_files_page.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/// All POD Files page - Displays all folders on the POD for testing.
///
/// 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:solidui/solidui.dart';

/// A page that browses all folders and files on the POD from the root.

class AllPodFilesPage extends StatelessWidget {
const AllPodFilesPage({super.key});

@override
Widget build(BuildContext context) {
return const SolidFile(
currentPath: SolidFile.podRoot,
friendlyFolderName: 'All POD Files',
showBackButton: true,
backButtonText: 'Back to POD Root',
);
}
}
137 changes: 137 additions & 0 deletions lib/src/utils/solid_file_operations_download.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,126 @@ import 'package:solidui/src/utils/solid_pod_helpers.dart';
class SolidFileDownloadOperations {
const SolidFileDownloadOperations._();

/// Checks if a file is within the current app's folder on the POD.
///
/// Returns `true` if the file belongs to the current app, indicating that
/// the current app can decrypt this file.
/// Returns `false` if the file is from another app's folder, meaning
/// decryption may fail as the security key is not available.

static Future<bool> _isFileInCurrentAppFolder(String filePath) async {
try {
// Validate that the file path is a POD-relative path rather than an
// absolute URL or empty string.

if (filePath.trim().isEmpty) {
debugPrint('Cannot check app folder ownership: file path is empty.');
return false;
}

if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
debugPrint(
'Cannot check app folder ownership: expected a POD-relative '
'path but received an absolute URL: $filePath',
);
return false;
}

// Resolve the relative file path into a full URL for reliable
// comparison.

final normalisedPath = PathUtils.normalise(filePath);
final fileUrl = await getFileUrl(normalisedPath);

// Derive the current app name from getDataDirPath(), which returns
// "APP_NAME/data". The first segment is the app name.

final appDataPath = await getDataDirPath();
if (appDataPath.isEmpty) return false;

final currentAppName = appDataPath.split('/').first;
if (currentAppName.isEmpty) return false;

// Build the current app's root directory URL and check whether the
// file URL falls under it. getDirUrl appends a trailing slash, which
// prevents false positives (e.g., "myapp2" matching "myapp").

final appRootUrl = await getDirUrl(currentAppName);

return fileUrl.startsWith(appRootUrl);
} catch (e) {
debugPrint('Error checking app folder ownership: $e');
return false;
}
}

/// Shows a warning dialogue when attempting to download an encrypted file
/// from another app's data folder.
///
/// Returns `true` if the user chooses to proceed with the download.
/// Returns `false` if the user cancels.

static Future<bool> _showCrossAppDownloadWarning(
BuildContext context,
) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28),
SizedBox(width: 12),
Expanded(child: Text('Cross-App Download Warning')),
],
),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'This file belongs to another application\'s data folder.',
style: TextStyle(fontWeight: FontWeight.w500),
),
SizedBox(height: 12),
Text(
'The file browser can browse files across all app folders in '
'your POD, but can only decrypt files within the current app\'s '
'data folder.',
),
SizedBox(height: 12),
Text(
'The file content may be encrypted by the other application. '
'If so, the security key required to decrypt it is not '
'available, and the downloaded file might be unreadable.',
),
SizedBox(height: 16),
Text(
'Do you still wish to proceed with the download?',
style: TextStyle(fontStyle: FontStyle.italic),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
child: const Text('Download Anyway'),
),
],
),
);

return result ?? false;
}

/// Default file download implementation.

static Future<void> downloadFile(
Expand All @@ -52,6 +172,23 @@ class SolidFileDownloadOperations {
String filePath,
) async {
try {
// Check if the file belongs to another app's folder. If so, warn the
// user that the file content may be encrypted.

final isInCurrentAppFolder = await _isFileInCurrentAppFolder(filePath);

if (!isInCurrentAppFolder) {
if (!context.mounted) return;

final shouldProceed = await _showCrossAppDownloadWarning(context);

if (!shouldProceed) {
return;
}
}

if (!context.mounted) return;

// Let user choose where to save the file.

final cleanFileName = fileName.replaceAll('.enc.ttl', '');
Expand Down
40 changes: 26 additions & 14 deletions lib/src/widgets/solid_file.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,24 +202,32 @@ class _SolidFileState extends State<SolidFile> {

/// Resolves the base path asynchronously.
///
/// If basePath is provided, uses it directly.
/// Otherwise, defaults to the app data directory path.
/// Falls back to pod root if the app data directory cannot be determined.
/// If [widget.currentPath] is explicitly provided, it is used as the base
/// path. This allows the widget to start from any location on the POD,
/// including the root. Otherwise, defaults to the app data directory path
/// via [getDataDirPath]. Falls back to POD root if the app data directory
/// cannot be determined.

Future<void> _resolveBasePath() async {
try {
final appDataPath = await getDataDirPath();
_resolvedBasePath = PathUtils.normalise(appDataPath);
} catch (e) {
// Fall back to POD root if getDataDirPath fails.

debugPrint('Failed to get app data path, falling back to POD root: $e');
_resolvedBasePath = SolidFile.podRoot;
if (widget.currentPath != null) {
// Use the explicitly provided path as the base.

_resolvedBasePath = PathUtils.normalise(widget.currentPath!);
} else {
try {
final appDataPath = await getDataDirPath();
_resolvedBasePath = PathUtils.normalise(appDataPath);
} catch (e) {
// Fall back to POD root if getDataDirPath fails.

debugPrint(
'Failed to get app data path, falling back to POD root: $e',
);
_resolvedBasePath = SolidFile.podRoot;
}
}

_currentPath = widget.currentPath != null
? PathUtils.normalise(widget.currentPath!)
: _resolvedBasePath!;
_currentPath = _resolvedBasePath!;
setState(() {
_isResolvingBasePath = false;
});
Expand Down Expand Up @@ -380,6 +388,9 @@ class _SolidFileState extends State<SolidFile> {
}

/// Builds the file browser widget.
///
/// Passes the resolved [_currentPath] as the initial path so the browser
/// starts from the correct location (e.g., POD root for "All POD Files").

Widget _buildFileBrowser() {
return SolidFileBrowserBuilder.build(
Expand All @@ -390,6 +401,7 @@ class _SolidFileState extends State<SolidFile> {
widget.autoConfig,
widget.friendlyFolderName,
),
initialPath: widget.currentPath,
onFileSelected: widget.onFileSelected,
onFileDownload: widget.onFileDownload,
onFileDelete: widget.onFileDelete,
Expand Down
Loading