Bidirectional file sync between device storage and Google Drive, with conflict resolution.
- Three OAuth scope modes —
drive(full),drive.file(app-created files only),drive.appdata(hidden app folder). Consumer picks based on architecture and compliance needs — see OAuth Scopes & Compliance - Push, pull, or bidirectional sync of any local directory to a Google Drive folder
- SHA256-based change detection — only transfers files that actually changed
- Conflict resolution — newerWins, localWins, remoteWins, or askUser (return conflicts to your UI)
- Path traversal + query injection protection — always on, regardless of scope mode
- Pluggable adapter interface — Google Drive included; implement
DriveAdapterfor iCloud, S3, etc. - No database — state tracked via a single JSON manifest file
- Platform-agnostic — sync engine uses callbacks for file I/O, no direct
dart:iodependency
dependencies:
drive_sync_flutter: ^1.2.0Pick a constructor that matches your OAuth scope. See OAuth Scopes & Compliance below for the full comparison.
import 'package:google_sign_in/google_sign_in.dart';
import 'package:drive_sync_flutter/drive_sync_flutter.dart';
// 1. Authenticate with the scope that matches the adapter mode you want.
final googleSignIn = GoogleSignIn(scopes: [
// Pick one:
'https://www.googleapis.com/auth/drive', // userDrive mode
// 'https://www.googleapis.com/auth/drive.file', // appFiles mode
// 'https://www.googleapis.com/auth/drive.appdata' // appData mode
]);
final account = await googleSignIn.signIn();
final authClient = GoogleAuthClient(await account!.authHeaders);
// 2. Create an adapter. Three options — pick the one matching your scope.
// Option A: appFiles — drive.file scope. No CASA. App only sees files it created.
final adapter = GoogleDriveAdapter.appFiles(
httpClient: authClient,
folderName: 'MyApp',
subPath: 'backups',
);
// Option B: userDrive — full drive scope. Arbitrary base path. Triggers CASA for public distribution.
// final adapter = GoogleDriveAdapter.userDrive(
// httpClient: authClient,
// basePath: '.app/longeviti', // any path you choose
// subPath: 'plans',
// );
// Option C: appData — drive.appdata scope. Hidden folder, invisible in Drive UI.
// final adapter = GoogleDriveAdapter.appData(
// httpClient: authClient,
// subPath: 'cache',
// );
// 3. Create a sync client and sync.
final client = DriveSyncClient(
adapter: adapter,
defaultStrategy: ConflictStrategy.newerWins,
);
final result = await client.sync(localPath: '/path/to/local/data');
print('${result.filesUploaded} uploaded, ${result.filesDownloaded} downloaded');The library supports all three Google Drive OAuth scopes. Your choice determines what files the app can see, whether you need CASA (annual security audit), and what tradeoffs you're making.
| Constructor | OAuth scope | App sees | User sees | CASA needed? |
|---|---|---|---|---|
.userDrive() |
drive (full) |
Everything in the user's Drive | Files visible in Drive UI | Yes for public distribution |
.appFiles() |
drive.file |
Only files this app created | Files visible in Drive UI | No |
.appData() |
drive.appdata |
Only contents of hidden appDataFolder |
Nothing (folder is hidden) | No |
Use .appFiles() if: Your app is the only writer. No CLI tool, no companion web app, no Drive Desktop drops, no manual user uploads to the sync folder. This is the 80% case and the lowest compliance burden.
Use .userDrive() if: Files in the sync folder are written by more than one OAuth client — for example, a CLI tool on a laptop plus a mobile app, or Drive Desktop drops that the app needs to read. Full drive scope is the only way for the app to see files created by other identities. This is a restricted scope; public distribution on Play Store / App Store requires Google OAuth verification plus annual CASA (details below).
Use .appData() if: You're syncing internal state the user should never see — app config, caches, encrypted blobs. The appDataFolder is invisible in the Drive UI, quota-separate from the user's Drive, and strictly scoped to this OAuth client ID.
drive.file is scoped by creating OAuth client ID, not by path. If any actor other than your Flutter app writes into the folder — the user manually, Google Drive Desktop syncing up a local file, a companion CLI tool — those files are invisible to your app's listFiles() call, even if they live in the same folder.
If your architecture has multiple writers (common for "sync my CLI output to my phone" patterns), .appFiles() will silently hide the other writers' files. You'll only discover this in production, when a user says "where are my plans?"
.userDrive() uses the drive scope, which Google classifies as restricted. Public distribution requires:
- Google OAuth verification — one-time review, free, takes 1–4 weeks. Brand/domain verification + privacy policy review + scope-justification video.
- Annual CASA (Cloud Application Security Assessment) — third-party security audit by a Google-approved lab (Bishop Fox, Leviathan, NCC Group, Security Innovation, etc.). Tier 2 is the common minimum: ~$5K–$20K/year. Includes pen test, SAST/DAST scan, token-storage review, deletion-flow review.
Can you skip CASA? Yes, if you keep your OAuth client in Testing publishing status. Constraints:
- Up to 100 test users (listed by Gmail address)
- Users see a "Google hasn't verified this app" warning on first sign-in (one-time per user)
- Refresh tokens for restricted scopes expire every 7 days — users re-sign-in weekly
Testing mode is the legitimate path for personal apps, family tools, and small-circle distribution. The 100-user cap is the hard ceiling. Distribution channel (direct APK, TestFlight, sideload, flutter run) is orthogonal to CASA — only the Consent Screen publishing status matters.
Workspace escape hatch: If you have a Google Workspace domain and all users have accounts on it, you can set the Consent Screen user type to Internal. Internal apps skip verification entirely — no CASA, no 100-user cap, no 7-day re-auth. Only works if your user base is inside a Workspace org.
Every mode validates all path arguments before construction. These rules apply uniformly across .userDrive(), .appFiles(), .appData(), and the legacy .sandboxed():
- No path traversal —
..segments rejected - No absolute paths — leading
/rejected - No empty segments —
//rejected - No trailing slash —
path/rejected - No dot segments —
.rejected - Query injection prevention — all file names and folder names escaped before interpolation into Drive API queries
Invalid arguments throw ArgumentError before any adapter instance is created — no Drive API calls are made until you try to sync.
If the auth client's actual scope doesn't match the adapter's declared scope, the first Drive API call will 403. The library catches this and re-raises as DriveScopeError with a clear remediation message:
DriveScopeError(declared=DriveScope.fullDrive): Drive API returned 403 (...).
The auth client likely does not have the required OAuth scope for this
adapter mode (declared: DriveScope.fullDrive). Verify the http.Client was
obtained with the matching scope.
.userDrive(basePath: '.app/longeviti', subPath: 'plans')
└── User's Google Drive
└── .app/
└── longeviti/
└── plans/ ← synced files here
.appFiles(folderName: 'MyApp', subPath: 'backups')
└── User's Google Drive
└── MyApp/
└── backups/ ← synced files here (visible to user;
app can only see what it created)
.appData(subPath: 'cache')
└── Hidden appDataFolder (invisible to user)
└── cache/ ← synced files here
.sandboxed(), the default GoogleDriveAdapter(), and .withPath() are all deprecated. They still work with zero behavior changes — migration is optional. The recommended replacements:
| Old | New | Notes |
|---|---|---|
GoogleDriveAdapter.sandboxed(appName: 'longeviti', subPath: 'x') |
GoogleDriveAdapter.userDrive(basePath: '.app/longeviti', subPath: 'x') |
Same behavior, explicit scope |
GoogleDriveAdapter(folderName: 'x') |
GoogleDriveAdapter.userDrive(basePath: 'x') or .appFiles(folderName: 'x') |
Pick based on scope you want |
GoogleDriveAdapter.withPath(folderPath: 'a/b/c') |
GoogleDriveAdapter.userDrive(basePath: 'a/b/c') |
Same behavior, explicit scope |
The high-level API for syncing a local directory with a remote folder.
final client = DriveSyncClient(adapter: adapter);
// Bidirectional sync (default) — new files go both ways, conflicts resolved by strategy
final result = await client.sync(localPath: '/data');
// Push only — local overwrites remote
await client.push(localPath: '/data');
// Pull only — remote overwrites local
await client.pull(localPath: '/data');
// Check what would change without syncing
final status = await client.status(localPath: '/data');
print('Pending: ${status.pendingChanges?.totalChanges ?? 0}');Handles Google Drive file operations. Three constructors, one per OAuth scope.
// drive.file — recommended default. No CASA. App sees only its own files.
final adapter = GoogleDriveAdapter.appFiles(
httpClient: authClient,
folderName: 'MyApp',
subPath: 'data',
);
// Full drive — when multiple OAuth clients write to the same folder.
final adapter = GoogleDriveAdapter.userDrive(
httpClient: authClient,
basePath: '.app/longeviti', // any path — no fixed prefix
subPath: 'plans',
);
// drive.appdata — hidden per-client folder, invisible to user.
final adapter = GoogleDriveAdapter.appData(
httpClient: authClient,
subPath: 'cache',
);
// Multiple adapters for the same app are fine — they don't interfere.
final plans = GoogleDriveAdapter.appFiles(
httpClient: authClient, folderName: 'MyApp', subPath: 'plans',
);
final backups = GoogleDriveAdapter.appFiles(
httpClient: authClient, folderName: 'MyApp', subPath: 'backups',
);Inspect the declared scope on an existing adapter:
final adapter = GoogleDriveAdapter.appFiles(httpClient: c, folderName: 'MyApp');
print(adapter.scope); // DriveScope.driveFile
print(adapter.folderPath); // 'MyApp'Convenience wrapper that injects Google auth headers into HTTP requests. Bridges google_sign_in with googleapis.
final account = await GoogleSignIn(scopes: ['drive']).signIn();
final authClient = GoogleAuthClient(await account!.authHeaders);
// Pass authClient to GoogleDriveAdapter.appFiles() / .userDrive() / .appData()Utility class for path validation. Used internally by every GoogleDriveAdapter constructor, also available for custom validation in your own code.
// Structural validation — always on regardless of scope mode
SandboxValidator.validateBasePath('.app/longeviti'); // passes
SandboxValidator.validateBasePath('MyApp/data'); // passes
SandboxValidator.validateBasePath('../hack'); // throws ArgumentError
SandboxValidator.validateBasePath('/absolute'); // throws ArgumentError
SandboxValidator.validateSubPath('Plans'); // passes
SandboxValidator.validateSubPath(null); // passes (optional)
SandboxValidator.validateSubPath('a/../b'); // throws ArgumentError
SandboxValidator.validateFolderName('MyApp'); // passes
SandboxValidator.validateFolderName('a/b'); // throws ArgumentError (use subPath)
SandboxValidator.joinBasePath('MyApp', 'data'); // 'MyApp/data'
// Naming convention — only used by legacy .sandboxed()
SandboxValidator.validateAppName('my_app'); // passes (lowercase snake_case)
SandboxValidator.validateAppName('MyApp'); // throws ArgumentError
// Query injection prevention — applied automatically inside the adapter
SandboxValidator.escapeDriveQuery("it's"); // returns "it\\'s"When both local and remote have modified the same file, the library picks one version — it does NOT merge content. There is no three-way merge, no content-aware diffing. It compares SHA256 checksums (to detect changes) and lastModified timestamps (to pick a winner). This works equally well for JSON, binary, or encrypted files since it never reads file contents.
| Strategy | Behavior |
|---|---|
newerWins |
Most recent lastModified wins. Ties go to local. |
localWins |
Always keep the local version (remote is overwritten). |
remoteWins |
Always keep the remote version (local is overwritten). |
askUser |
Skips the file and returns it in result.unresolvedConflicts for your UI to handle. |
Important: The losing version is overwritten. If you need to preserve both versions, use askUser and implement your own merge or backup logic.
final client = DriveSyncClient(
adapter: adapter,
defaultStrategy: ConflictStrategy.remoteWins, // plans folder: remote is truth
);Implement DriveAdapter to sync with any cloud provider:
class S3Adapter implements DriveAdapter {
@override
Future<void> ensureFolder() async { /* create bucket/prefix */ }
@override
Future<Map<String, RemoteFileInfo>> listFiles() async { /* list objects */ }
@override
Future<void> uploadFile(String path, List<int> content) async { /* PUT object */ }
@override
Future<List<int>> downloadFile(String path) async { /* GET object */ }
@override
Future<void> deleteFile(String path) async { /* DELETE object */ }
}DriveSyncClient <- High-level API (sync/push/pull/status)
|
+-- SyncEngine <- Orchestrates diff -> resolve -> transfer
|
+-- ManifestDiffer <- Compares file states (added/modified/deleted/unchanged)
+-- ConflictResolver <- Applies conflict strategy
+-- DriveAdapter <- File I/O interface
|
+-- GoogleDriveAdapter <- Google Drive v3 implementation
|
+-- SandboxValidator <- Path validation + query escaping
Manifest: A JSON file (_sync_manifest.json) stored alongside your local data tracks {path, sha256, lastModified} for each synced file. Only files that changed since the last sync are transferred.
- Syncs files (any format: JSON, YAML, images, binary, encrypted blobs) between a local directory and a Google Drive folder
- Detects changes via SHA256 checksums — only transfers files that actually differ
- Resolves conflicts when the same file is modified locally and remotely
- Creates nested folder hierarchies on Drive automatically
- Tracks sync state via a local manifest file (
_sync_manifest.json) - Validates all paths structurally — rejects traversal, absolute paths, empty segments, and escapes query strings — regardless of scope mode
- No encryption. Files are transferred as-is. If you need encryption, encrypt before syncing and decrypt after pulling.
- No content merging. Conflict resolution picks one version (local or remote) — it never merges file contents.
- No authentication. You must provide an authenticated
http.Client(e.g., viagoogle_sign_in). - No background sync. Sync is triggered explicitly by your code.
- No partial/resumable uploads. Files are uploaded/downloaded in full. Not suitable for files larger than ~50MB.
- No file locking or concurrency control. Designed for single-device use.
| Concern | Who handles it |
|---|---|
| OAuth flow (sign-in, token refresh) | You — use google_sign_in or equivalent |
| Providing an authenticated HTTP client | You — wrap with GoogleAuthClient or your own |
| Encryption of sensitive data | You — encrypt before sync, decrypt after pull |
| File format and schema validation | You — library treats files as opaque bytes |
| Retry logic on network failure | You — library returns errors in SyncResult.errors |
| Background/periodic sync scheduling | You — call sync() when appropriate |
| Google Cloud project setup (OAuth consent, client IDs) | You — required for google_sign_in to work |
| Concern | Who handles it |
|---|---|
| Change detection (SHA256) | Library |
| Manifest tracking | Library |
| Conflict resolution | Library (configurable strategy) |
| Google Drive CRUD (list, upload, download, delete) | Library (GoogleDriveAdapter) |
| Folder creation on Drive | Library (nested paths supported) |
| Path sandboxing and validation | Library (SandboxValidator) |
| Query injection prevention | Library (escaped query values) |
| Error reporting per file | Library (via SyncResult.errors) |
Your app needs:
- Google Drive scope:
https://www.googleapis.com/auth/drive(full) orhttps://www.googleapis.com/auth/drive.file(app-created files only) - Internet access
- Device storage (read/write local files)
dart test117 tests covering manifest diffing, conflict resolution, sync engine flows, all three scope modes (userDrive, appFiles, appData), path validation, query injection prevention, backward compatibility with legacy constructors, and the full DriveSyncClient lifecycle.
MIT