Skip to content

Flutter quickstart: uploads from a mobile app

This quickstart adds photo and video uploads to the Flutter app of a social app, with progress, pause, resume after the app was killed, and cancel. It uses ccs_files_user_client, the Dart counterpart of the web client. It is for Flutter developers; your backend creates tickets with the Node client, or hands out user tokens.

The package is private and lives in the ccs-file-server repository. Add it at a release tag, with path: pointing at the package:

dependencies:
ccs_files_user_client:
git:
url: git@github.com:central-code-station/ccs-file-server.git
ref: ccs-files-flutter-v1.3.0 # a release tag, never a moving branch
path: libs/flutter/ccs_files_user_client

Then flutter pub get. It runs on Android, iOS, macOS, Windows and Linux. It does not run on Flutter web (it reads files through dart:io); in a browser, use the web client.

The recommended flow for a mobile app: the app holds no File Service token. Your API checks its own rule (the user wrote the post), creates the upload ticket with the Node client (files.uploads.create, as in the backend quickstart) and answers the ticket as JSON; the app sends the bytes; your API completes the ticket and links the file to the post in the same request.

attach_to_post.dart
import 'dart:io';
import 'package:ccs_files_user_client/ccs_files_user_client.dart';
import 'shared/my_api.dart';
/// The backend-mediated flow: the app holds no File Service token. Your API creates the ticket,
/// the app sends the bytes, your API completes the ticket and links the file to the post.
Future<String> attachToPost(MyApi api, String postId, File file, void Function(double) onProgress) async {
final String name = file.uri.pathSegments.last;
final UploadTicket ticket = UploadTicket.fromJson(
await api.createUpload(postId,
name: name, size: await file.length(), mimeType: guessMimeType(name) ?? 'application/octet-stream'),
);
final UploadController upload = UploadController.fromTicket(
ticket,
UploadSource.file(file),
// More part URLs: above 100 parts, or when one is older than its hour.
refreshPartUrls: (int from, int count) async =>
(await api.uploadParts(ticket.uploadId, from, count)).map(UploadPartUrl.fromJson).toList(),
);
upload.progress.listen((UploadProgress p) => onProgress(p.fraction));
final UploadResult result = await upload.start(); // result.file is null: your API completes it
// completionBody() is { parts: [{ part_number, etag }] }, empty parts for a single PUT.
return api.completeUpload(postId, result.uploadId, result.completionBody());
}
  • UploadController.fromTicket runs the same engine as a client upload: one PUT for a small file, parts with 4 in flight for a large one, each retried with backoff, with pause, resume and progress.
  • start() completes with an UploadResult whose file is null: the app cannot complete the ticket. completionBody() is { "parts": [{ "part_number", "etag" }] }, exactly the body your API passes to files.uploads.complete.
  • The ticket carries the first 100 part URLs, each valid 1 hour. Pass refreshPartUrls (your API calls files.uploads.parts) for larger files or slow networks; without it a missing or expired part URL fails the upload with part_url_expired.
  • cancel() cannot release the ticket without a token: tell your API, which calls files.uploads.abort (an unused ticket also expires on its own).

When the app also browses folders, searches or shows files under the user’s own permissions, it needs a user token from your backend’s token route. TokenProvider asks the route, caches the token until a minute before it expires, and asks again on a 401.

User scope: files:access
files.dart
import 'package:ccs_files_user_client/ccs_files_user_client.dart';
import 'shared/my_api.dart';
/// The client of the signed-in user, from your backend's token route. The token is cached until a
/// minute before it expires; a 401 asks the route again once.
Future<FilesClient> createFilesClient(MyApi api) {
final TokenProvider tokens = TokenProvider(
tokenUrl: Uri.parse('https://api.example.com/files-token'),
headers: () async => <String, String>{'authorization': 'Bearer ${await api.accessToken()}'},
);
// The route answers api_url, tenant_id and application_id, so the client configures itself.
return FilesClient.fromTokenRoute(tokens);
}
/// Uploads a picked file as the user, with progress, and waits for the answer.
Future<String> uploadPhoto(FilesClient files, String path, String postId, void Function(double) onProgress) async {
final PublicSettings settings = await files.settings.public();
if (!settings.allows(guessMimeType(path) ?? '')) throw StateError('This type is not allowed here');
final UploadController upload = files.upload(
UploadSource.path(path),
UploadRequest(externalRef: 'post:$postId', visibility: Visibility.application, onNameConflict: 'rename'),
);
upload.progress.listen((UploadProgress p) => onProgress(p.fraction));
// upload.pause(), upload.resume() and upload.cancel() from your buttons.
final UploadResult result = await upload.start(); // the file is available; processing may still run
return result.fileId;
}

settings.public() holds the application’s upload rules: check a picked file with allows(mimeType) before you start. A refusal at the ticket (type_not_allowed, file_too_large, quota_exceeded) surfaces before any byte is sent.

Every upload has a JSON snapshot: the ticket, the part URLs, the parts the store confirmed with their ETags, and what identifies the source (a file’s path, length and modification time). FilesUploadQueue keeps the snapshots for you, runs a few uploads at a time, and continues them at the next start and whenever the app returns to the foreground:

uploads.dart
import 'dart:io';
import 'package:ccs_files_user_client/ccs_files_user_client.dart';
/// Uploads that survive the app being killed: the queue saves every upload's snapshot (the ticket,
/// the confirmed parts and their ETags, the file's path, length and modification time) after every
/// change, and continues them at the next start and whenever the app returns to the foreground.
Future<FilesUploadQueue> startQueue(FilesClient files, Directory appSupport) async {
final FilesUploadQueue queue = FilesUploadQueue(
client: files,
store: JsonFileUploadStore(File('${appSupport.path}/ccs_files_uploads.json')), // app-private storage
concurrency: 2, // uploads at once, each with its own parts in flight
);
await queue.restore(); // asks the service which parts the store has, then sends only the rest
return queue;
}
/// Adds a picked file; the queue starts it when a slot is free.
UploadController enqueue(FilesUploadQueue queue, String path, String postId) =>
queue.add(UploadSource.path(path), UploadRequest(externalRef: 'post:$postId'));

On resume, the client asks the service which parts the store already has and sends only the missing ones. A file that changed since (another length or modification time) is refused with FilesUploadSourceMismatchException. Files are reopened by their path; a stream or bytes source needs resolveSource. The snapshots contain signed URLs: keep the store in the app’s private storage. Without the queue, persist upload.toJson() on every checkpoints event and rebuild it with files.resumeUpload(json, source).

preview.dart
import 'package:ccs_files_user_client/ccs_files_user_client.dart';
/// What to show for a file: the URLs are cached until a minute before they expire.
Future<String?> previewUrl(FilesClient files, String fileId) async {
final Preview preview = await files.preview(fileId);
switch (preview.kind) {
case PreviewKind.image || PreviewKind.pdf || PreviewKind.text || PreviewKind.audio:
return preview.url;
case PreviewKind.video:
// mp4_720p when it is ready, then mp4_1080p, else the original. Play it with a video player widget.
return preview.url;
case PreviewKind.none:
return null; // Office documents, archives, a file still processing: offer a download
}
}
/// A feed page's thumbnails in one request, in the order of the ids.
Future<Map<String, String?>> thumbnails(FilesClient files, List<String> fileIds) async {
final List<UrlResult> items = await files.urls(fileIds, rendition: 'thumbnail');
return <String, String?>{for (final UrlResult item in items) item.fileId: item.url};
}

preview and urls are cached until a minute before the URLs expire; never store a URL, store the file_id. A video’s url is its mp4_720p rendition when it is ready, then mp4_1080p, otherwise the original; Office documents preview as none. See Previews and renditions. When the application waits for the malware scan, a new file’s URLs answer 409 scan_pending until the scan is clean (Malware scanning).