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.
Install
Section titled “Install”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_clientThen 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.
Upload a ticket your backend created
Section titled “Upload a ticket your backend created”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.
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.fromTicketruns 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 anUploadResultwhosefileisnull: the app cannot complete the ticket.completionBody()is{ "parts": [{ "part_number", "etag" }] }, exactly the body your API passes tofiles.uploads.complete.- The ticket carries the first 100 part URLs, each valid 1 hour. Pass
refreshPartUrls(your API callsfiles.uploads.parts) for larger files or slow networks; without it a missing or expired part URL fails the upload withpart_url_expired. cancel()cannot release the ticket without a token: tell your API, which callsfiles.uploads.abort(an unused ticket also expires on its own).
Upload as the user
Section titled “Upload as the user”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.
files:accessimport '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.
Resume after the app was killed
Section titled “Resume after the app was killed”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:
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).
Show the file
Section titled “Show the file”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).
Next steps
Section titled “Next steps”- ccs_files_user_client: the whole API, the upload engine’s rules and the errors.
- What to store in your database.
- Events:
file.readythrough the Notification Service.