Android SDK
Native Kotlin SDK for Abrova Storage on Android. Uses coroutines and OkHttp. Sign users in with an Abrova user token (userTokenProvider), upload with progress, list, download, delete, public image links and image transforms.
Native Kotlin SDK for Android applications. Uses coroutines for async operations and OkHttp for networking.
Installation#
Install from Abrova Center in two steps.
// 1. settings.gradle (or the project build.gradle): add Abrova Center
repositories {
google()
mavenCentral()
maven { url "https://center.abrova.ir/api/packages/abrova/maven" }
}// 2. build.gradle.kts (app module)
dependencies {
implementation("ir.abrova:abrova-storage:0.2.1")
}Add <uses-permission android:name="android.permission.INTERNET" /> to AndroidManifest.xml.
Usage#
import ir.abrova.storage.*
val storage = AbrovaStorage.builder("ab_live_xxxxxxxxxxxx")
// The signed-in user's Abrova user token, see step 3.
// Called on a background thread. Return null when no one is signed in.
.userTokenProvider { myBackend.fetchAbrovaToken() }
.build()
// Upload a file, with an optional progress callback (main thread)
val file = storage.upload(
bucketId = "my-bucket",
path = "photos/avatar.jpg",
data = imageBytes, // ByteArray
options = UploadOptions(contentType = "image/jpeg"),
onProgress = { p -> progressBar.progress = (p.fraction * 100).toInt() },
)
println("Uploaded: ${file.id}")
// List files
val result = storage.listFiles(
bucketId = "my-bucket",
options = ListFilesOptions(prefix = "photos/", limit = 20),
)
result.files.forEach { println(" - ${it.path} (${it.size} bytes)") }
// Download
val bytes = storage.download(fileId = file.id)
// Delete (follows the bucket's delete rules)
storage.delete(fileId = file.id)All network methods are suspend functions. Call them from a coroutine scope (e.g., viewModelScope.launch { ... }).
The last progress call has bytesSent == totalBytes. totalBytes is the whole upload request: your file plus a few hundred bytes of form fields.
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:
val auth = AbrovaAuth.shared
val storage = AbrovaStorage.builder("ab_live_xxxxxxxxxxxx")
// Called off the main thread, so the blocking provider is the right one.
.userTokenProvider(auth.blockingTokenProvider)
.onAwaitingUserToken { showSignIn() } // optional; once per wait, background thread
.build()
// After sign-in or sign-out
auth.addAuthStateListener { 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 userTokenProvider { myBackend.fetchAbrovaToken() } fetches it. Call storage.refreshUserToken() when the user signs in or out, or pass a token you hold with storage.setUserToken(token) (null signs out). The SDK sends the token as x-user-token and gets a new one (retrying the request once, uploads included) when it 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, and go on by themselves, in order, once a user signs in. This is by design: show your sign-in screen while they wait (storage.isAwaitingUserToken is true meanwhile). Bucket reads (listBuckets, getBucket, getBucketByName) and getPublicUrl never wait. Cancelling the coroutine also ends the wait. A refused token with no replacement throws UserTokenRejectedException.
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:
val storage = AbrovaStorage.builder("ab_live_xxxxxxxxxxxx")
.userTokenProvider(auth.blockingTokenProvider)
.userTokenWaitTimeout(30, TimeUnit.SECONDS) // fails with UserTokenRequiredException after 30 s
.build()A call that waited that long throws AbrovaStorageException.UserTokenRequiredException (token_required). 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 (Log.d("Storage", "waiting for a user")) so a silent wait is easy to spot.
Upgrading from 0.2.0: userId / setUserId() are removed. Use the token provider.
Showing 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 Coil or Glide:
// A 600 px wide WebP, straight into Coil (Compose)
val url = storage.getPublicUrl(file.id, ImageTransforms(width = 600, format = "webp"))
AsyncImage(model = url, contentDescription = null)
// Views + Glide: Glide.with(imageView).load(url).into(imageView)
// A public link you stored earlier: the same URL, no request
val thumb = AbrovaStorage.publicImageUrl(
savedLink,
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: load the image again after that. - Private buckets have no public link (
getPublicUrlfails with 403). Download the bytes, or loadgetTransformUrlwith your headers:
// Private file: download the bytes and decode them
val bytes = storage.download(fileId = file.id)
imageView.setImageBitmap(BitmapFactory.decodeByteArray(bytes, 0, bytes.size))
// Or the transform address, which needs the key and user token headers
val thumbnailUrl = storage.getTransformUrl(
fileId = file.id,
transforms = ImageTransforms(width = 200, height = 200, fit = "cover", format = "webp"),
)
imageView.load(thumbnailUrl) {
addHeader("x-api-key", "ab_live_xxxxxxxxxxxx")
addHeader("x-user-token", userToken) // the signed-in user's token
}Image options: format jpeg (default), png, webp or avif; fit cover, contain, fill, inside or outside; width / height 1 or more (above 4096 sent as 4096); quality 1-100; blur 0-20; sharpen 0-10; rotate degrees. Anything else throws invalid_transform before a URL is built. A signed link for a private file comes from your server (getSignedUrl in the Node.js SDK).
Handling errors#
Every AbrovaStorageException the server caused carries its HTTP statusCode and, when the server named the reason, its code (constants in StorageErrorCode):
try {
storage.upload("photos", "clips/a.mov", bytes)
} catch (e: AbrovaStorageException) {
when {
e.isTypeNotAllowed -> showError("This bucket doesn't take this file type")
e.isFileTooLarge -> showError("The file is too big")
e.code == StorageErrorCode.PATH_TAKEN -> showError("A file with this name already exists")
e.code == StorageErrorCode.ACCESS_DENIED -> showError("You can't write here")
else -> showError(e.message ?: "Upload failed")
}
}code is null when the server gave no reason (a 404, a 429), and statusCode is null when no answer arrived (NetworkException).
| 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 below). |
| 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. |
Files, buckets and paths#
- A
StorageFilehasid,bucketId,path,fileName,mimeType,size(Long),checksum,metadata,uploadedBy,uploadedAt,createdAtandupdatedAt(checksum,metadata,uploadedByanduploadedAtarenullwhen empty). Dates arejava.util.Date. - A
Buckethasid,name,description,visibility,allowedMimeTypes(null= every type),maxFileSize,blockExecutables,createdAtandupdatedAt. listFilesanswersfilesandnextCursor(nullon the last page).- Paths. Pass paths as you write them: spaces,
#,?and non-Latin characters are encoded for you. A.or..part is refused withinvalid_pathbefore anything is sent, and a new file's path can't contain?,#or%. - When you pass no
contentType, the SDK sends the type for the file's extension (photo.HEICasimage/heic,notes.mdastext/markdown). - The SDK always connects to Abrova Storage over HTTPS; there is no
baseUrloption.