Flutter SDK
Native Dart SDK for Flutter mobile and web. Sign users in with an Abrova user token (tokenProvider), upload, download, list, delete, image transforms, and a profile-image example with image_picker via the abrova_storage package.
Native Dart SDK for Flutter mobile and web applications. Covers file uploads, downloads and image transformations.
Installation#
Install from Abrova Center in two steps.
# 1. pubspec.yaml: install from Abrova Center
dependencies:
abrova_storage:
hosted: https://center.abrova.ir/api/packages/abrova/pub
version: ^0.2.1# 2. Install flutter pub get
Initialization#
import 'package:abrova_storage/abrova_storage.dart';
final storage = AbrovaStorage(
apiKey: 'ab_live_xxxxxxxxxxxx',
// The signed-in user's Abrova user token, see "Sign users in" below.
// Return null when no one is signed in.
tokenProvider: () => myBackend.fetchAbrovaToken(),
);
// Dispose when done (e.g., in your State's dispose())
@override
void dispose() {
storage.dispose(); // calls still waiting for a user fail with code 'disposed'
super.dispose();
}The Flutter SDK uses only the apiKey. File upload, read, list and delete work from the app and follow the bucket's access rules. Creating and changing buckets and access rules, and making signed links, belong on your backend (Node.js SDK with serverSecret). The SDK always connects to Abrova Storage over HTTPS; there is no baseUrl option.
Sign users in (one token for every Abrova product)#
File requests from apps always need a signed Abrova user token. There is no setting to turn this off. Bucket rules that use {userId} match the user in that token. It is the same token Abrova Sync, Chat, Push and A/B Testing accept.
With Abrova Auth, no backend is needed. Its token is this token:
final auth = AbrovaAuth.instance; final storage = AbrovaStorage( apiKey: 'ab_live_xxxxxxxxxxxx', tokenProvider: auth.tokenProvider, ); // After sign-in or sign-out auth.authStateChanges.listen((_) => storage.refreshUserToken());
With your own server, your backend mints the token with POST https://auth.abrova.ir/users/token and your server secret (it never ships in an app), and tokenProvider fetches it, as in step 2.
// When the user signs in or out await storage.refreshUserToken(); // Or pass a token you already hold (null signs out) await storage.setUserToken(token); // Or set / replace the provider later await storage.setTokenProvider(() => myBackend.fetchAbrovaToken());
The SDK sends the token as x-user-token, asks the provider before the first request, and asks again (retrying the request once) when the token has expired or is refused.
Waiting for a user
With no token (no one signed in), file calls (upload, list, get, download, delete) are not sent and do not fail: they wait. They go on by themselves, in order, once a user signs in. This is by design: show your sign-in screen while they wait. Bucket reads (listBuckets, getBucket, getBucketByName) and getPublicUrl need no user and never wait.
final storage = AbrovaStorage( apiKey: 'ab_live_xxxxxxxxxxxx', tokenProvider: auth.tokenProvider, ); storage.onAwaitingUserToken(() => showSignIn()); // once per wait storage.isAwaitingUserToken; // true while file calls wait for a user
A token the server keeps refusing throws the same exception with token_expired or token_invalid.
Don't wait on file calls before sign-in. A file call made while no one is signed in finishes only after sign-in. Don't wait for it in code the user is waiting on (a button handler, a startup check): start it in the background, or after sign-in.
Leave the timeout unset. By default a call waits until sign-in, which is right for background work. Set userTokenWaitTimeout only when a screen waits for the answer and can send the user to sign-in:
final storage = AbrovaStorage( apiKey: 'ab_live_xxxxxxxxxxxx', tokenProvider: auth.tokenProvider, userTokenWaitTimeout: const Duration(seconds: 30), // throws token_required after 30 s );
A call that waited that long throws an AbrovaStorageTokenException with code == 'token_required' (status 401). The timeout option is a different setting: it limits each network request (default 30 seconds) and never covers the wait for a user.
With Abrova Auth, turn on the sign-in method you use (for example Anonymous) in Abrova Auth › Sign-in methods. If it is off, sign-in fails with METHOD_DISABLED, no one is signed in, and file calls wait without an error. While developing, log from onAwaitingUserToken (debugPrint('Storage: waiting for a user')) so a silent wait is easy to spot.
Upgrading from 0.2.0: userId / setUserId() are removed. Use tokenProvider.
Upload Files#
import 'dart:io';
import 'dart:typed_data';
import 'package:abrova_storage/abrova_storage.dart';
// Upload from Uint8List (bytes)
final Uint8List imageBytes = await File('path/to/image.jpg').readAsBytes();
final file = await storage.upload(
'my-bucket',
'images/photo.jpg',
imageBytes,
options: UploadOptions(
contentType: 'image/jpeg',
metadata: {'source': 'camera'},
),
// Optional: about every 50 ms or 1%; the last call has sent == total
onProgress: (sent, total) => setState(() => _progress = sent / total),
);
print('Uploaded: ${file.id}');
print('Size: ${file.size} bytes');total is the whole upload request: your file plus a few hundred bytes of form fields. On Flutter web the browser takes the whole file at once, so progress jumps to 100% when the upload starts. When you pass no contentType, the SDK sends the type for the file's extension (photo.HEIC as image/heic, notes.md as text/markdown).
Paths. Pass paths as you write them: spaces, #, ? and non-Latin characters are encoded for you. A . or .. part is refused with invalid_path before anything is sent, and a new file's path can't contain ?, # or %.
Download Files#
// Download file content as Uint8List
final Uint8List bytes = await storage.download(fileId);
// Save to local file
final file = File('${directory.path}/downloaded.jpg');
await file.writeAsBytes(bytes);
// Or display directly
Image.memory(bytes);List Files#
// List files in a bucket
final result = await storage.listFiles(
'my-bucket',
options: ListFilesOptions(
prefix: 'images/',
limit: 50,
),
);
for (final file in result.files) {
print('${file.path}: ${file.size} bytes');
}
// Pagination: nextCursor is null on the last page
if (result.nextCursor != null) {
final nextPage = await storage.listFiles(
'my-bucket',
options: ListFilesOptions(cursor: result.nextCursor),
);
}A StorageFile has id, bucketId, path, fileName, mimeType, size, checksum, metadata, uploadedBy, uploadedAt, createdAt and updatedAt (checksum, metadata, uploadedBy and uploadedAt are null when empty). A Bucket has id, name, description, visibility, allowedMimeTypes (null = every type), maxFileSize, blockExecutables, createdAt and updatedAt. Dates are DateTime (UTC).
Delete Files#
// Deletes follow the bucket's delete rules
// Delete a file by ID
await storage.delete(fileId);
// Delete a file by path
await storage.deleteByPath('my-bucket', 'images/photo.jpg');
// Delete multiple files at once
final result = await storage.deleteMany([
'file_id_1',
'file_id_2',
'file_id_3',
]);
print('Deleted ${result.deleted} files');Show Images#
For a file in a public bucket, getPublicUrl returns a link that needs no key, token or header. With image options it returns a resized or converted image, ready for a bare Image.network(url):
// A 600 px wide WebP, straight into Image.network
final url = await storage.getPublicUrl(file.id,
transforms: const ImageTransforms(width: 600, format: 'webp'));
Image.network(url);
// A public link you stored earlier: the same URL, no request
final thumb = AbrovaStorage.publicImageUrl(savedLink,
transforms: const ImageTransforms(width: 200, height: 200, fit: 'cover', format: 'webp'));- Cached, so use a few fixed sizes. The same options always give the same URL, made once and then served from the cache. One image keeps at most 50 variants; a new one beyond that is refused with
too_many_variants(429). Pick a few sizes (for example 200, 600 and 1200 wide). - Usage. When the month's 500 free transforms are used up and your wallet cannot pay for more, an image URL with options answers
plan_feature_locked(403). The plain public link always works. - Busy.
transform_busy(503) comes with aRetry-Afterheader in seconds.Image.networkcalls itserrorBuilder: load the image again after that (for example on a retry tap). - Private buckets have no public link (
getPublicUrlfails with 403).downloadthe bytes, or loadgetTransformUrlwith your headers:
// The transform address needs the key and user token headers
final thumbnailUrl = storage.getTransformUrl(
fileId,
transforms: const ImageTransforms(width: 200, height: 200, fit: 'cover', format: 'webp'),
);
Image.network(
thumbnailUrl,
headers: {
'x-api-key': 'ab_live_xxxxxxxxxxxx',
'x-user-token': userToken, // the signed-in user's token
},
);
// All available options
const options = ImageTransforms(
width: 800, // 1 or more (above 4096 sent as 4096)
height: 600,
fit: 'contain', // cover, contain, fill, inside, outside
format: 'webp', // jpeg (default), png, webp, avif
quality: 85, // 1-100
blur: 5, // 0-20
sharpen: 2, // 0-10
rotate: 90, // degrees (multiples of 90 recommended)
);getTransformUrl and publicImageUrl throw invalid_transform for any other value, before a URL is built. A signed link for a private file comes from your server (getSignedUrl in the Node.js SDK).
Complete Example: Profile Image Upload#
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:abrova_storage/abrova_storage.dart';
class ProfileService {
final AbrovaStorage _storage;
ProfileService(String apiKey, AbrovaStorageTokenProvider tokenProvider)
: _storage = AbrovaStorage(apiKey: apiKey, tokenProvider: tokenProvider);
// Uploads to the public "avatars" bucket and returns a 200 px thumbnail link
Future<String?> uploadProfileImage(String userId) async {
final picker = ImagePicker();
final pickedFile = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1024,
maxHeight: 1024,
imageQuality: 85,
);
if (pickedFile == null) return null;
final bytes = await pickedFile.readAsBytes();
final file = await _storage.upload(
'avatars',
'users/$userId/avatar.jpg',
bytes,
options: UploadOptions(contentType: 'image/jpeg'),
);
return _storage.getPublicUrl(
file.id,
transforms: const ImageTransforms(width: 200, height: 200, fit: 'cover', format: 'webp'),
);
}
// No headers needed for a public link
Widget avatar(String url) => Image.network(url);
void dispose() {
_storage.dispose();
}
}Error Handling#
Every AbrovaStorageException has the server's message, the HTTP statusCode and, when the server named the reason, its code (constants in StorageErrorCode). code is null when the server gave no reason (a 404, a 429), and statusCode is null when no answer arrived.
try {
await storage.upload('photos', 'clips/a.mov', bytes);
} on AbrovaStorageTokenException catch (e) {
// token_required / token_expired / token_invalid: send the user to sign in
print('Sign-in needed: ${e.code}');
} on AbrovaStorageException catch (e) {
if (e.isTypeNotAllowed) {
showError("This bucket doesn't take this file type");
} else if (e.isFileTooLarge) {
showError('The file is too big');
} else if (e.code == StorageErrorCode.pathTaken) {
showError('A file with this name already exists');
} else if (e.code == StorageErrorCode.accessDenied) {
showError("You can't write here");
} else {
showError(e.message);
}
}| Status | code | When |
|---|---|---|
| 401 | token_required | No user signed in within userTokenWaitTimeout. |
| 401 | token_expired, token_invalid | The user token was refused and the provider had no other one. |
| 403 | access_denied | The bucket's access rules refused the request. |
| 400 | type_not_allowed, file_too_large, executable_blocked | The upload doesn't fit the bucket's file settings. |
| 409 | path_taken | A file already exists at this path. |
| 400 | invalid_path | The path was refused (see Upload Files). |
| 400 | invalid_transform | An image option is not allowed. |
| 403 | plan_feature_locked | No transforms left: the free monthly transforms are used up and the wallet cannot pay for more. |
| 429 | too_many_variants | This image already has 50 sizes / formats. |
| 503 | transform_busy | Image processing is busy. Retry after Retry-After seconds. |
| - | disposed | dispose() was called while the call waited for a user. |
API Reference#
| Option | Type | Required | Description |
|---|---|---|---|
| listBuckets() | - | - | List all buckets in the project |
| getBucket(id) | - | - | Get bucket by ID |
| getBucketByName(name) | - | - | Get bucket by name |
| upload(bucket, path, data, options:, onProgress:) | - | - | Upload a file to a bucket, with optional progress |
| listFiles(bucket, options:) | - | - | List files in a bucket |
| getFile(id) | - | - | Get file metadata by ID |
| getFileByPath(bucket, path) | - | - | Get file metadata by path |
| download(id) | - | - | Download file content as bytes |
| delete(id) | - | - | Delete a file by ID |
| deleteByPath(bucket, path) | - | - | Delete a file by path |
| deleteMany(ids) | - | - | Delete multiple files at once |
| getPublicUrl(id, transforms:) | - | - | Public link (public buckets only), optionally a resized / converted image; needs no headers |
| AbrovaStorage.publicImageUrl(publicUrl, transforms:) | - | - | Build the image link from a saved public link, without a request |
| getUrl(id) | - | - | Address of the public-link endpoint; needs x-api-key and answers { url } for files in a public bucket |
| getTransformUrl(id, transforms:) | - | - | Transform address; needs the x-api-key and x-user-token headers |
| getDownloadUrl(id) | - | - | Download address; needs the x-api-key and x-user-token headers |
| refreshUserToken() | - | - | Ask the token provider again (call on sign-in / sign-out) |
| setUserToken(token) | - | - | Use this Abrova user token; null signs out |
| setTokenProvider(provider) | - | - | Set or replace the token provider |
| isAwaitingUserToken | - | - | True while file calls wait for a user |
| onAwaitingUserToken(callback) | - | - | Called once each time file calls start waiting for a user |
| dispose() | - | - | Close the HTTP client; waiting calls fail with code disposed |