Skip to content
android-supabase logo

Android Supabase Skill

android-supabase

Supabase integration patterns for Android - authentication, database, realtime subscriptions. Use when setting up Supabase SDK, implementing OAuth, querying database, or setting up realtime.

SKILL.md

Full skill instructions

Android Supabase Skill

Supabase integration patterns for Android with Kotlin, Jetpack Compose, and Clean Architecture.

When to Use

  • Setting up Supabase SDK in Android project
  • Implementing Google OAuth with Credential Manager
  • Database CRUD operations (select, insert, update, upsert, delete)
  • Real-time subscriptions
  • Managing user sessions

Setup

Dependencies (libs.versions.toml)

[versions]
supabase = "3.2.6"
ktor = "3.1.3"

[libraries]
supabase-bom = { module = "io.github.jan-tennert.supabase:bom", version.ref = "supabase" }
supabase-postgrest = { module = "io.github.jan-tennert.supabase:postgrest-kt" }
supabase-gotrue = { module = "io.github.jan-tennert.supabase:gotrue-kt" }
supabase-realtime = { module = "io.github.jan-tennert.supabase:realtime-kt" }
ktor-client = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
# Google Credential Manager
credentials = { module = "androidx.credentials:credentials", version = "1.3.0" }
credentials-play = { module = "androidx.credentials:credentials-play-services-auth", version = "1.3.0" }
googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version = "1.1.1" }

Client Factory Pattern

object SupabaseClientFactory {
    fun create(): SupabaseClient {
        return createSupabaseClient(
            supabaseUrl = BuildConfig.SUPABASE_URL,
            supabaseKey = BuildConfig.SUPABASE_ANON_KEY
        ) {
            install(Auth)
            install(Postgrest)
            install(Realtime) // Optional
        }
    }
}

Koin DI Module

val supabaseModule = module {
    single { SupabaseClientFactory.create() }
    single<AuthService> { AuthServiceSupabaseImpl(get(), get()) }
    single<UserService> { UserServiceSupabaseImpl(get(), get()) }
}

Google OAuth with Credential Manager

Critical: Use Web Client ID (not Android Client ID) for native app authentication.

AuthService Implementation

class AuthServiceSupabaseImpl(
    private val supabaseClient: SupabaseClient,
    private val dispatchers: QzDispatchers
) : AuthService {

    override val authState: Flow<AuthState> = supabaseClient.auth.sessionStatus.map {
        when (it) {
            is SessionStatus.Authenticated -> {
                val user = it.session.user ?: return@map AuthState.Guest
                AuthState.Authenticated(
                    AuthUser(
                        uid = user.id,
                        email = user.email.orEmpty(),
                        displayName = user.userMetadata?.get("full_name")?.jsonPrimitive?.content.orEmpty(),
                        photoUrl = user.userMetadata?.get("avatar_url")?.jsonPrimitive?.content.orEmpty(),
                    )
                )
            }
            else -> AuthState.Guest
        }
    }

    override suspend fun signInWithGoogleCredential(
        context: Context,
        serverClientId: String
    ) = withContext(dispatchers.io) {
        runCatching {
            val credentialManager = CredentialManager.create(context)

            // Generate nonce for security
            val rawNonce = UUID.randomUUID().toString()
            val hashedNonce = MessageDigest.getInstance("SHA-256")
                .digest(rawNonce.toByteArray())
                .fold("") { str, it -> str + "%02x".format(it) }

            // Build Google ID option
            val googleIdOption = GetGoogleIdOption.Builder()
                .setFilterByAuthorizedAccounts(false)
                .setServerClientId(serverClientId)
                .setNonce(hashedNonce)
                .build()

            val request = GetCredentialRequest.Builder()
                .addCredentialOption(googleIdOption)
                .build()

            // Get credential from Google
            val result = credentialManager.getCredential(request, context)
            val googleIdToken = GoogleIdTokenCredential.createFrom(result.credential.data).idToken

            // Sign in with Supabase
            supabaseClient.auth.signInWith(IDToken) {
                idToken = googleIdToken
                provider = Google
                nonce = rawNonce
            }
        }.fold(
            { Result.success(Unit) },
            { Result.failure(it) }
        )
    }

    override suspend fun signOut() = withContext(dispatchers.io) {
        runCatching { supabaseClient.auth.signOut() }
            .fold({ Result.success(Unit) }, { Result.failure(it) })
    }
}

UseCase Pattern

class SignInWithGoogleUseCase(
    private val authService: AuthService,
    private val secretManager: SecretManager
) : UseCase<Context, Result<Unit>>() {
    override suspend operator fun invoke(input: Context): Result<Unit> {
        val serverClientId = secretManager.getStaticSecret(StaticSecretKey.GOOGLE_OAUTH_CLIENT_ID)
        return authService.signInWithGoogleCredential(input, serverClientId)
    }
}

Database Operations

SELECT with Filter Builder

// Single record with filter
suspend fun fetchUser(uuid: String): User? = withContext(dispatchers.io) {
    runCatching {
        supabaseClient.from("users")
            .select {
                filter {
                    eq("auth_uuid", uuid)
                }
            }
            .decodeSingleOrNull<User>()
    }.getOrNull()
}

// List with pagination and ordering
suspend fun fetchLeaderboard(limit: Int): List<Entry> = withContext(dispatchers.io) {
    try {
        supabaseClient.from("leaderboard")
            .select {
                order("score", Order.DESCENDING)
                limit(limit)
            }
            .decodeList<Entry>()
    } catch (e: Exception) {
        emptyList()
    }
}

// Complex filters
val results = supabaseClient.from("questions")
    .select {
        filter {
            eq("category", "science")
            gte("difficulty", 3)
            neq("status", "draft")
        }
        order("created_at", Order.DESCENDING)
        limit(20)
        offset(page * 20)
    }
    .decodeList<Question>()

INSERT

// Single insert
supabaseClient.from("scores")
    .insert(ScoreEntry(userId = id, score = 100))

// Batch insert
supabaseClient.from("questions")
    .insert(listOf(question1, question2, question3))

// Insert and return
val inserted = supabaseClient.from("users")
    .insert(newUser)
    .decodeSingle<User>()

UPDATE

// Update with filter
supabaseClient.from("users")
    .update(mapOf("name" to "New Name", "updated_at" to now))
    .eq("id", userId)

// Update with object
supabaseClient.from("settings")
    .update(UserSettings(theme = "dark")) {
        filter { eq("user_id", userId) }
    }

UPSERT (Insert or Update)

// Upsert with conflict column
suspend fun upsertUser(request: UpsertUserRequest) = withContext(dispatchers.io) {
    runCatching {
        supabaseClient.from("users").upsert(request) {
            onConflict = "auth_uuid"
        }
    }.fold(
        onSuccess = { Result.success(Unit) },
        onFailure = { Result.failure(it) }
    )
}

DELETE

// ALWAYS include filter - omitting deletes entire table!
supabaseClient.from("sessions")
    .delete {
        filter {
            eq("user_id", userId)
            lt("expires_at", now)
        }
    }

Real-time Subscriptions

// Flow-based reactive pattern
supabaseClient.from("leaderboard")
    .selectAsFlow()
    .collect { entries: List<Entry> ->
        _leaderboard.value = entries
    }

// Manual channel subscription
val channel = supabase.channel("scores")
channel.postgresChangeFlow<PostgresAction.Insert>(schema = "public") {
    table = "scores"
}.collect { change ->
    handleNewScore(change.record)
}
channel.subscribe()

// Cleanup
channel.unsubscribe()

Note: Enable replication in Supabase Console > Settings > Replication.

Data Models

@Serializable
data class User(
    val id: String = "",
    @SerialName("auth_uuid") val authUuid: String = "",
    val email: String = "",
    val name: String = "",
    @SerialName("created_at") val createdAt: String? = null,
    @SerialName("updated_at") val updatedAt: String? = null
)

@Serializable
data class LeaderboardEntry(
    val id: String = "",
    @SerialName("user_id") val userId: String = "",
    val score: Int = 0,
    val rank: Int = 0,
    @SerialName("display_name") val displayName: String = ""
)

Error Handling Pattern

suspend fun <T> safeSupabaseCall(block: suspend () -> T): Result<T> = try {
    Result.success(block())
} catch (e: RestException) {
    Timber.e("Supabase REST error: ${e.message}")
    Result.failure(e)
} catch (e: HttpRequestTimeoutException) {
    Timber.e("Supabase timeout")
    Result.failure(e)
} catch (e: Exception) {
    Timber.e("Supabase error: ${e.message}")
    Result.failure(e)
}

Troubleshooting

IssueSolution
401 UnauthorizedCheck API key, refresh session
403 ForbiddenVerify RLS policies, check user permissions
Serialization errorCheck @SerialName mappings, provide defaults
OAuth failsUse Web Client ID, verify SHA-1 fingerprint
Network timeoutIncrease Ktor timeout in client config
Real-time not workingEnable replication in Supabase console

Security Checklist

  • Store credentials in local.properties (not committed)
  • Enable RLS on all tables
  • Never expose service_role key in app
  • Use @SerialName for snake_case DB columns
  • Validate user input before queries
  • Handle session expiry gracefully

Version Compatibility

Supabase SDKKotlinKtorMin SDK
3.2.x2.0+3.x26
3.0.x1.9+2.x24

References