Claude-skill-registry 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.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/android-supabase" ~/.claude/skills/majiayu000-claude-skill-registry-android-supabase && rm -rf "$T"
manifest: skills/data/android-supabase/SKILL.md
source content

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