8000 fix(app): stop PathAccessException from blocking startup by shroominic · Pull Request #224 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content

Navigation Menu

Draft
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ expectations are defined in [docs/compatibility.md](docs/compatibility.md).

## [Unreleased]

### Fixed

- App startup no longer fails with `PathAccessException` when the shared
`OpenGlucose` application-support directory had been created with iOS
`FileProtectionType.complete` (applied to intermediate directories by an
older HealthDatabase prepare). Restricted storage now uses
`completeUntilFirstUserAuthentication`, repairs the product directory on
launch, and pins `path_provider` platform implementations away from the
FFI/JNI rewrites that have produced production path-resolution failures.

## [0.1.6] - 2026-08-31

### Added
Expand Down
15 changes: 10 additions & 5 deletions docs/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,19 @@ Never hand-edit a resolved lockfile to imitate a package-manager result.
repository, advisory, and license sources.
3. Prefer the smallest maintained dependency with a compatible license and no
unnecessary permissions, services, telemetry, or build-time downloads.
4. Update through the native package manager; inspect manifest, lockfile,
4. `dependency_overrides` need a time-bounded exception with an in-manifest
comment naming the defect and removal condition. The app currently pins
`path_provider_foundation`/`path_provider_android` off the FFI/JNI rewrites
that failed OpenGlucose startup path resolution; remove the pins once
endorsed releases are re-verified on iOS and Android launch.
5. Update through the native package manager; inspect manifest, lockfile,
platform project, permission, entitlement, and generated-registration diffs.
5. Run package-wide checks plus affected native builds and integration tests.
6. Exercise timeout, denial, unavailable-service, malformed-input, and rollback
6. Run package-wide checks plus affected native builds and integration tests.
7. Exercise timeout, denial, unavailable-service, malformed-input, and rollback
behavior for third-party integrations.
7. Update documentation, license inventory/notice material, compatibility notes,
8. Update documentation, license inventory/notice material, compatibility notes,
and changelogs as appropriate.
8. Record the rollback version and any migration constraint in the pull request.
9. Record the rollback version and any migration constraint in the pull request.

Do not combine a dependency upgrade with unrelated behavior or broad formatting.

Expand Down
16 changes: 11 additions & 5 deletions docs/privacy/data-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,17 @@ target even on failure so a recoverable rescan is preferred to backup exposure.
Android configuration disables backup/device transfer and excludes app domains;
final device verification remains release evidence. On iOS, the implementation
requests `NSURLIsExcludedFromBackupKey`, reads the attribute back, and fails the
restricted-store startup if it is not confirmed. Local tests exercise the
storage behavior. Physical app-container inspection on `Shroominic` confirmed
the current restricted directory and files were backup-excluded in build 17 on
2026-08-14; a full backup/restore rehearsal remains separate evidence. These are
implementation and device-check claims, not proof of store enforcement.
restricted-store startup if it is not confirmed. Restricted health-state and
local SQLite health-database paths use
`FileProtectionType.completeUntilFirstUserAuthentication` so they remain
readable after first unlock; startup also repairs the shared `OpenGlucose`
parent if an older build applied `complete` protection to intermediate
directories (which previously failed launch with `PathAccessException`). Local
tests exercise the storage behavior. Physical app-container inspection on
`Shroominic` confirmed the current restricted directory and files were
backup-excluded in build 17 on 2026-08-14; a full backup/restore rehearsal
remains separate evidence. These are implementation and device-check claims,
not proof of store enforcement.

The dedicated file is JSON and is not encrypted by OpenGlucose itself. It
relies on the application sandbox and operating-system at-rest protections;
Expand Down
7 changes: 7 additions & 0 deletions openhealth/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ PODS:
- FlutterMacOS
- health (13.3.1):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
Expand All @@ -25,6 +28,7 @@ DEPENDENCIES:
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- health (from `.symlinks/plugins/health/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
Expand All @@ -40,6 +44,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
health:
:path: ".symlinks/plugins/health/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
Expand All @@ -53,6 +59,7 @@ SPEC CHECKSUMS:
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
Expand Down
48 changes: 38 additions & 10 deletions openhealth/ios/Runner/PrivacyStorageChannel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,15 +96,25 @@ final class PrivacyStorageChannel {
}
}

/// Establishes a dedicated database directory with the strongest iOS data-at-
/// rest class that remains practical for foreground health-data access.
/// Establishes a dedicated database directory with startup-accessible iOS
/// data-at-rest protection.
///
/// Protection is `completeUntilFirstUserAuthentication`, matching the native
/// restricted-state store. `FileProtectionType.complete` is intentionally
/// avoided: it is unavailable before first unlock after reboot and can fail
/// Flutter startup with `PathAccessException` when BLE/background launch races
/// the first unlock. Intermediate directories (notably the shared
/// `OpenGlucose` parent) are created without a protection attribute so sibling
/// stores such as restricted health state are not locked out.
///
/// The directory and every SQLite artifact are protected and excluded before
/// sqflite opens the database. Empty sidecars are valid SQLite inputs and let
/// us set attributes before SQLite can put health data in WAL/SHM/journal files.
enum ProtectedHealthDatabaseStorage {
private static let fileManager = FileManager.default
private static let sidecarSuffixes = ["", "-wal", "-shm", "-journal"]
private static let startupAccessibleProtection =
FileProtectionType.completeUntilFirstUserAuthentication

static func prepare(directoryPath: String, databasePath: String) throws {
let locations = try validatedLocations(
Expand All @@ -122,6 +132,9 @@ enum ProtectedHealthDatabaseStorage {
guard fileManager.fileExists(atPath: sourceURL.path) else {
throw PrivacyStorageError.invalidArtifact
}
// Repair any previously applied complete-protection class that would make
// restricted paths unreadable at the next cold start before first unlock.
try applyAndVerifyStartupAccessibleProtection(sourceURL)
var url = sourceURL
do {
var values = URLResourceValues()
Expand Down Expand Up @@ -174,18 +187,31 @@ enum ProtectedHealthDatabaseStorage {
}

private static func prepareDirectory(_ directory: URL) throws {
let parent = directory.deletingLastPathComponent()
// Create shared ancestors without a protection attribute. FileManager
// applies createDirectory attributes to intermediate directories too, so
// putting complete/until-first-auth on this call would poison OpenGlucose
// for sibling restricted-state paths.
if parent.path != directory.path {
try fileManager.createDirectory(
at: parent,
withIntermediateDirectories: true,
attributes: nil
)
try applyAndVerifyStartupAccessibleProtection(parent)
}
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true,
withIntermediateDirectories: false,
attributes: [
.protectionKey: FileProtectionType.complete,
.protectionKey: startupAccessibleProtection,
]
)
let attributes = try fileManager.attributesOfItem(atPath: directory.path)
guard attributes[.type] as? FileAttributeType == .typeDirectory else {
throw PrivacyStorageError.invalidArtifact
}
try applyAndVerifyCompleteProtection(directory)
try applyAndVerifyStartupAccessibleProtection(directory)
try excludeFromBackupAndVerify(directory)
}

Expand All @@ -199,26 +225,28 @@ enum ProtectedHealthDatabaseStorage {
let created = fileManager.createFile(
atPath: file.path,
contents: Data(),
attributes: [.protectionKey: FileProtectionType.complete]< 10BC8 /span>
attributes: [.protectionKey: startupAccessibleProtection]
)
guard created else {
throw PrivacyStorageError.fileCreationFailed
}
}
try applyAndVerifyCompleteProtection(file)
try applyAndVerifyStartupAccessibleProtection(file)
try excludeFromBackupAndVerify(file)
}

private static func applyAndVerifyCompleteProtection(_ url: URL) throws {
private static func applyAndVerifyStartupAccessibleProtection(
_ url: URL
) throws {
do {
try fileManager.setAttributes(
[.protectionKey: FileProtectionType.complete],
[.protectionKey: startupAccessibleProtection],
ofItemAtPath: url.path
)
let attributes = try fileManager.attributesOfItem(atPath: url.path)
guard
let protection = attributes[.protectionKey] as? FileProtectionType,
protection == .complete
protection == startupAccessibleProtection
else {
throw PrivacyStorageError.fileProtectionFailed
}
Expand Down
7 changes: 5 additions & 2 deletions openhealth/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:openglucose/src/app_language_controller.dart';
import 'package:openglucose/src/app_localizations_extension.dart';
import 'package:openglucose/src/app_controller.dart';
import 'package:openglucose/src/archived_sensor_export_diagnostics.dart';
import 'package:openglucose/src/bootstrap_failure_label.dart';
import 'package:openglucose/src/dashboard_chart.dart';
import 'package:openglucose/src/display_preferences.dart';
import 'package:openglucose/src/driver_factory.dart';
Expand Down Expand Up @@ -178,12 +179,14 @@ class _SplashApp extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const _SpinningLogo(),
if (error != null) ...<Widget>[
if (error case final Object failure) ...<Widget>[
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
context.l10n.failedToStart(error.runtimeType.toString()),
context.l10n.failedToStart(
bootstrapFailureLabel(failure),
),
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFFB24A3B)),
),
Expand Down
21 changes: 21 additions & 0 deletions openhealth/lib/src/bootstrap_failure_label.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'dart:io' show FileSystemException;

/// Identifier-free bootstrap failure label for the splash screen.
///
/// Prefer the OS error message over a filesystem path so install/container
/// identifiers never appear in the first-run failure surface.
String bootstrapFailureLabel(Object error) {
if (error is FileSystemException) {
final message = error.osError?.message.trim();
if (message != null && message.isNotEmpty) {
return '${error.runtimeType}: $message';
}
}
if (error is StateError) {
final message = error.message.trim();
if (message.isNotEmpty) {
return message;
}
}
return error.runtimeType.toString();
}
28 changes: 24 additions & 4 deletions openhealth/lib/src/health_state_store_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:io';

import 'package:crypto/crypto.dart' as crypto;
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

Expand Down Expand Up @@ -83,23 +84,42 @@ class FileHealthStateStore implements HealthStateStore {
}

Future<void> _initialize() async {
try {
await _initializeStorage();
} on PathAccessException catch (error) {
throw StateError(
'Restricted health-state storage is not writable at startup: '
'${error.osError?.message ?? error.message}',
);
}
}

Future<void> _initializeStorage() async {
final applicationSupport = await _directoryProvider();
final productDirectory = Directory(
p.join(applicationSupport.path, 'OpenGlucose'),
);
final directory = Directory(
'${applicationSupport.path}${Platform.pathSeparator}OpenGlucose'
'${Platform.pathSeparator}$_storageDirectoryName',
p.join(productDirectory.path, _storageDirectoryName),
);
await productDirectory.create(recursive: true);
// Repair the shared product directory before creating restricted files.
// A prior HealthDatabase prepare used FileProtectionType.complete with
// intermediate directories, which could leave OpenGlucose unreadable at
// the next cold start and surface as PathAccessException on launch.
await _excludeFromBackup(productDirectory.path);
await directory.create(recursive: true);
// Exclude and verify the dedicated directory before any transaction file
// can contain sensor identity or glucose history. File-level verification
// remains in place so every committed artifact is independently checked.
await _excludeFromBackup(directory.path);
final historyDirectory = Directory(
'${directory.path}${Platform.pathSeparator}$_historyDirectoryName',
p.join(directory.path, _historyDirectoryName),
);
await historyDirectory.create(recursive: true);
await _excludeFromBackup(historyDirectory.path);
_historyDirectory = historyDirectory;
final file = File('${directory.path}${Platform.pathSeparator}$_fileName');
final file = File(p.join(directory.path, _fileName));
_file = file;

await _restoreInterruptedCommit(file);
Expand Down
2 changes: 2 additions & 0 deletions openhealth/macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Foundation
import device_info_plus
import flutter_blue_plus_darwin
import flutter_secure_storage_darwin
import path_provider_foundation
import share_plus
import shared_preferences_foundation
import sqflite_darwin
Expand All @@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterBluePlusPlugin.register(with: registry.registrar(forPlugin: "FlutterBluePlusPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
Expand Down
7 changes: 7 additions & 0 deletions openhealth/macos/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ PODS:
- Flutter
- FlutterMacOS
- FlutterMacOS (1.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
Expand All @@ -22,6 +25,7 @@ DEPENDENCIES:
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
Expand All @@ -35,6 +39,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin
FlutterMacOS:
:path: Flutter/ephemeral
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
Expand All @@ -47,6 +53,7 @@ SPEC CHECKSUMS:
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
Expand Down
Loading
Loading
0