Storage

The upload sample, Supabase Storage bucket, and media picker flow.

The upload sample is an Example feature: it shows a complete Android media picker -> Supabase Storage upload flow, but it is intentionally simple enough to replace with your own product-specific file model.

What it provides

  • Android photo/video picker via ActivityResultContracts.PickMultipleVisualMedia.
  • A private Supabase Storage bucket named sample-uploads.
  • Owner-scoped object paths under <user-id>/.
  • Sequential uploads with per-job progress from uploadAsFlow.
  • In-memory queue state for pending, uploading, complete, and failed jobs.

The Kotlin template does not use the Expo sample's sample_uploads metadata table. The UI tracks the current upload queue in memory.

Key files

Upload flow

Pick images or videos

UploadScreen creates a launcher with:

ActivityResultContracts.PickMultipleVisualMedia(MAX_PICK_COUNT)

It launches with PickVisualMedia.ImageAndVideo, so both images and videos are accepted. The screen reads display names, MIME types, and sizes through ContentResolver.

Require a signed-in user

UploadViewModel reads sessionController.currentUser.value?.id. If there is no user, it shows "Sign in to upload files." and does not touch Storage.

Validate size and bytes

Each file is capped at 25 MB. Empty files fail before upload.

Stream upload progress

UploadsRepository.upload() writes to the sample-uploads bucket and returns Flow<UploadStatus>. The ViewModel maps UploadStatus.Progress into row progress and UploadStatus.Success into a completed row with the final storage path.

Object path convention

UploadsRepository writes paths like:

<user-id>/<epoch-millis>-<sanitized-file-name>

The file-name sanitizer keeps only letters, numbers, ., _, and -, turns other runs into -, trims edge dashes, and falls back to upload when the name becomes blank.

Example:

11111111-1111-1111-1111-111111111111/1782940800000-vacation.mov

The folder-scoped Storage policies below depend on the first path segment being the Supabase user id.

Create the bucket and policies

Run this SQL after creating your Supabase project:

insert into storage.buckets (id, name, public, file_size_limit)
values ('sample-uploads', 'sample-uploads', false, 26214400)
on conflict (id) do update
  set public = excluded.public,
      file_size_limit = excluded.file_size_limit;

do $$
begin
  if not exists (
    select 1 from pg_policies
    where schemaname = 'storage'
      and tablename = 'objects'
      and policyname = 'Users can read own sample upload objects'
  ) then
    execute $policy$
      create policy "Users can read own sample upload objects"
        on storage.objects for select
        to authenticated
        using (
          bucket_id = 'sample-uploads'
          and (storage.foldername(name))[1] = auth.uid()::text
        )
    $policy$;
  end if;

  if not exists (
    select 1 from pg_policies
    where schemaname = 'storage'
      and tablename = 'objects'
      and policyname = 'Users can upload own sample upload objects'
  ) then
    execute $policy$
      create policy "Users can upload own sample upload objects"
        on storage.objects for insert
        to authenticated
        with check (
          bucket_id = 'sample-uploads'
          and (storage.foldername(name))[1] = auth.uid()::text
        )
    $policy$;
  end if;

  if not exists (
    select 1 from pg_policies
    where schemaname = 'storage'
      and tablename = 'objects'
      and policyname = 'Users can update own sample upload objects'
  ) then
    execute $policy$
      create policy "Users can update own sample upload objects"
        on storage.objects for update
        to authenticated
        using (
          bucket_id = 'sample-uploads'
          and (storage.foldername(name))[1] = auth.uid()::text
        )
        with check (
          bucket_id = 'sample-uploads'
          and (storage.foldername(name))[1] = auth.uid()::text
        )
    $policy$;
  end if;

  if not exists (
    select 1 from pg_policies
    where schemaname = 'storage'
      and tablename = 'objects'
      and policyname = 'Users can delete own sample upload objects'
  ) then
    execute $policy$
      create policy "Users can delete own sample upload objects"
        on storage.objects for delete
        to authenticated
        using (
          bucket_id = 'sample-uploads'
          and (storage.foldername(name))[1] = auth.uid()::text
        )
    $policy$;
  end if;
end $$;

The bucket limit matches the app's 25 MB check. If you raise one, raise the other.

Account deletion caveat

The current Kotlin delete-account implementation does not remove sample-uploads objects. It deletes todos, nulls profile fields, and signs out locally; the auth user and storage files remain.

The recommended template fix is the same one described in Authentication: add a service-role delete-account Edge Function that removes sample-uploads/<user-id>/ objects before deleting the Supabase Auth user.

Display and customize

The sample upload screen reports the final storage path but does not render a remote gallery. If your app needs previews, use Coil 3 (AppAsyncImage already wraps Coil for the UI kit) and decide whether your bucket should be private with signed URLs or public with CDN-backed object URLs.

To make this production-specific:

  • Rename UploadsRepository.BUCKET_ID.
  • Change the object path convention if your product needs albums, teams, or object ids as path segments.
  • Add a metadata table if files need titles, ownership beyond one user, review states, or listing/history.
  • Keep Storage policies in lockstep with the path shape.

Verify

  • sample-uploads exists and is private.
  • A signed-in user can upload an image below 25 MB.
  • The returned storage path starts with that user's UUID.
  • Another signed-in user cannot read or overwrite that object.
  • A signed-out user cannot upload.

On this page