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
48 changes: 46 additions & 2 deletions lib/core/infrastructure/platform_file_system_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,18 @@ class PlatformFileSystemService implements FileSystemService {
// The actual tracking is done in ProtectedFilesRegistry.registerExternalPath()
}

/// Gets the app documents directory with caching
/// Gets the model storage directory with caching.
///
/// On desktop platforms, uses platform-specific local app data directories
/// that are NOT synced to cloud services (OneDrive, iCloud, Dropbox).
/// This is critical because native code (LiteRT-LM JNI) cannot reliably
/// access files in cloud-synced folders.
///
/// Storage locations:
/// - Windows: %LOCALAPPDATA%\flutter_gemma (truly local, never synced)
/// - macOS: ~/Library/Application Support/flutter_gemma
/// - Linux: ~/.local/share/flutter_gemma
/// - Android/iOS: App documents directory (standard behavior)
Future<Directory> _getDocumentsDirectory() async {
// Web doesn't support local file system
if (kIsWeb) {
Expand All @@ -143,7 +154,40 @@ class PlatformFileSystemService implements FileSystemService {
return _documentsDirectory!;
}

_documentsDirectory = await getApplicationDocumentsDirectory();
if (Platform.isWindows) {
// Use LOCALAPPDATA on Windows - truly local and never synced
final localAppData = Platform.environment['LOCALAPPDATA'];
if (localAppData != null) {
_documentsDirectory = Directory(path.join(localAppData, 'OroForge', 'models'));
} else {
_documentsDirectory = await getApplicationSupportDirectory();
}
} else if (Platform.isMacOS) {
// Use Application Support on macOS - not synced by default
final home = Platform.environment['HOME'];
if (home != null) {
_documentsDirectory = Directory(path.join(home, 'Library', 'Application Support', 'OroForge', 'models'));
} else {
_documentsDirectory = await getApplicationSupportDirectory();
}
} else if (Platform.isLinux) {
// Use XDG data directory on Linux
final xdgDataHome = Platform.environment['XDG_DATA_HOME'];
if (xdgDataHome != null) {
_documentsDirectory = Directory(path.join(xdgDataHome, 'OroForge', 'models'));
} else {
final home = Platform.environment['HOME'];
if (home != null) {
_documentsDirectory = Directory(path.join(home, '.local', 'share', 'OroForge', 'models'));
} else {
_documentsDirectory = await getApplicationSupportDirectory();
}
}
} else {
// Mobile platforms use standard documents directory
_documentsDirectory = await getApplicationDocumentsDirectory();
}

return _documentsDirectory!;
}
}
68 changes: 65 additions & 3 deletions lib/core/model_management/utils/file_system_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,71 @@ class ModelFileSystemManager {
}
}

/// Cached model storage directory
static Directory? _modelStorageDirectory;

/// Gets the model storage directory.
///
/// On desktop platforms, uses local app data directories that are NOT synced
/// to cloud services (OneDrive, iCloud, Dropbox). This is critical because
/// native code (LiteRT-LM JNI) cannot reliably access files in cloud-synced
/// folders.
///
/// Storage locations:
/// - Windows: %LOCALAPPDATA%\flutter_gemma (truly local, never synced)
/// - macOS: ~/Library/Application Support/flutter_gemma
/// - Linux: ~/.local/share/flutter_gemma
/// - Android/iOS: App documents directory (standard behavior)
static Future<Directory> getModelStorageDirectory() async {
if (_modelStorageDirectory != null) {
return _modelStorageDirectory!;
}

if (Platform.isWindows) {
// Use LOCALAPPDATA on Windows - truly local and never synced
final localAppData = Platform.environment['LOCALAPPDATA'];
if (localAppData != null) {
_modelStorageDirectory = Directory(path.join(localAppData, 'OroForge', 'models'));
} else {
_modelStorageDirectory = await getApplicationSupportDirectory();
}
} else if (Platform.isMacOS) {
// Use Application Support on macOS - not synced by default
final localAppData = Platform.environment['HOME'];
if (localAppData != null) {
_modelStorageDirectory = Directory(path.join(localAppData, 'Library', 'Application Support', 'OroForge', 'models'));
} else {
_modelStorageDirectory = await getApplicationSupportDirectory();
}
} else if (Platform.isLinux) {
// Use XDG data directory on Linux
final xdgDataHome = Platform.environment['XDG_DATA_HOME'];
if (xdgDataHome != null) {
_modelStorageDirectory = Directory(path.join(xdgDataHome, 'OroForge', 'models'));
} else {
final home = Platform.environment['HOME'];
if (home != null) {
_modelStorageDirectory = Directory(path.join(home, '.local', 'share', 'OroForge', 'models'));
} else {
_modelStorageDirectory = await getApplicationSupportDirectory();
}
}
} else {
// Mobile platforms use standard documents directory
_modelStorageDirectory = await getApplicationDocumentsDirectory();
}

// Ensure directory exists
if (!await _modelStorageDirectory!.exists()) {
await _modelStorageDirectory!.create(recursive: true);
}

return _modelStorageDirectory!;
}

/// Gets the full file path for a model file with Android path correction
static Future<String> getModelFilePath(String filename) async {
final directory = await getApplicationDocumentsDirectory();
final directory = await getModelStorageDirectory();
return getCorrectedPath(directory.path, filename);
}

Expand All @@ -90,7 +152,7 @@ class ModelFileSystemManager {
List<String>? protectedFiles,
List<String>? supportedExtensions,
}) async {
final directory = await getApplicationDocumentsDirectory();
final directory = await getModelStorageDirectory();
final extensions = supportedExtensions ?? ModelFileSystemManager.supportedExtensions;
final protected = protectedFiles ?? [];

Expand Down Expand Up @@ -132,7 +194,7 @@ class ModelFileSystemManager {
static Future<StorageStats> getStorageInfo({
List<String>? protectedFiles,
}) async {
final directory = await getApplicationDocumentsDirectory();
final directory = await getModelStorageDirectory();

final files = directory
.listSync()
Expand Down
25 changes: 24 additions & 1 deletion lib/mobile/smart_downloader.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_gemma/core/domain/download_error.dart';
import 'package:flutter_gemma/core/domain/download_exception.dart';
import 'package:flutter_gemma/core/model_management/cancel_token.dart';
import 'package:path/path.dart' as p;

/// Smart downloader with HTTP-aware retry logic
///
Expand Down Expand Up @@ -177,7 +179,28 @@ class SmartDownloader {
StreamSubscription? listener;

try {
final (baseDirectory, directory, filename) = await Task.split(filePath: targetPath);
// CRITICAL: Task.split() has a bug on Windows where it strips the drive letter
// from paths that don't match known base directories, causing files to be written
// to the wrong location. We handle Windows paths manually to preserve the full path.
final BaseDirectory baseDirectory;
final String directory;
final String filename;

if (!kIsWeb && Platform.isWindows) {
// On Windows, use BaseDirectory.root with the FULL directory path including drive letter.
// background_downloader's baseDirectoryPath(BaseDirectory.root) returns '' on Windows,
// so path.join('', fullDirectory, filename) correctly produces the absolute path.
baseDirectory = BaseDirectory.root;
directory = p.dirname(targetPath); // Full directory with drive letter (e.g., C:\Users\...)
filename = p.basename(targetPath);
debugPrint('🔵 Windows path handling: dir=$directory, file=$filename');
} else {
// On other platforms, Task.split() works correctly
final splitResult = await Task.split(filePath: targetPath);
baseDirectory = splitResult.$1;
directory = splitResult.$2;
filename = splitResult.$3;
}

final task = DownloadTask(
url: url,
Expand Down