Skip to content

Commit 52bfd53

Browse files
authored
Merge pull request #13 from hieuwu/feature/email-confirmation
Handle account creation with email confirmation - sign out - auth state logging
2 parents facc0de + 512c8e9 commit 52bfd53

File tree

15 files changed

+335
-25
lines changed

15 files changed

+335
-25
lines changed

.idea/inspectionProfiles/Project_Default.xml

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,39 @@
1-
<h1 align="center"> 🚀 Product Sample Supabase</h1>
1+
# 🚀 Product Sample Supabase
22

33
[![Made with Supabase](https://supabase.com/badge-made-with-supabase-dark.svg)](https://supabase.com)
44

5-
<!-- <img width="964" alt="Screen Shot 2023-06-02 at 21 33 16" src="https://github.com/hieuwu/product-sample-supabase-kt/assets/43868345/288eef97-d8fe-422f-8bb3-1c8424bab08e">
5+
![Product Sample Cover](https://github.com/hieuwu/product-sample-supabase-kt/assets/43868345/1bed0c84-208a-4266-a2ec-2bac50ddf80c)
66

7-
<img width="1092" alt="Screen Shot 2023-06-05 at 21 39 24" src="https://github.com/hieuwu/product-sample-supabase-kt/assets/43868345/3f154fda-aa9e-4baa-81f2-5b744c7ad2fe"> -->
7+
## 📖 Overview
88

9-
![manage-product-cover](https://github.com/hieuwu/product-sample-supabase-kt/assets/43868345/1bed0c84-208a-4266-a2ec-2bac50ddf80c)
10-
[![Android CI](https://github.com/hieuwu/product-sample-supabase-kt/actions/workflows/app-build.yml/badge.svg)](https://github.com/hieuwu/product-sample-supabase-kt/actions/workflows/app-build.yml)
9+
This project is a modern Android application built with **Jetpack Compose**, showcasing **best practices** for integrating **Supabase** for **authentication**, **OAuth**, **storage**, and **real-time CRUD operations**. It serves as a reference for developers aiming to build scalable, maintainable Android apps with a robust backend.
1110

12-
### About
13-
Demonstration and best practices of how to use Supabase database for CRUD operation
11+
### 🎯 Features
12+
- **Supabase Integration**: Email/password authentication, OAuth (e.g., Google), file storage, and real-time database operations.
13+
- **Jetpack Compose**: Declarative UI for a responsive and modern user experience.
14+
- **Hilt Dependency Injection**: Clean architecture for modularity and testability.
15+
- **Real-time Data**: Leverages Supabase's real-time subscriptions for live updates.
16+
- **CI/CD**: Automated builds and testing via GitHub Actions.
17+
- **Image Loading**: Efficient image handling with Coil.
1418

15-
### Setup
16-
Android Studio with SDK 30 or above
17-
Open `local.properties` file, add these
18-
```kotlin
19-
API_KEY=YOUR_SUPABASE_API_KEY
20-
SECRET=YOUR_SUPABASE_SECRET
21-
SUPABASE_URL=YOUR_SUPABASE_URL
19+
## 🛠️ Setup
2220

23-
```
21+
### Prerequisites
22+
- **Android Studio**: SDK 30 (Android 11) or higher.
23+
- **Supabase Account**: Obtain API key, secret, and project URL from [Supabase](https://supabase.com).
24+
- **Kotlin**: Version 1.9.0 or higher recommended.
25+
26+
### Installation
27+
1. Clone the repository:
28+
```bash
29+
git clone https://github.com/hieuwu/product-sample-supabase-kt.git
30+
```
31+
2. Open the project in Android Studio.
32+
3. Create or update the `local.properties` file in the project root:
33+
```kotlin
34+
API_KEY=YOUR_SUPABASE_API_KEY
35+
SECRET=YOUR_SUPABASE_SECRET
36+
SUPABASE_URL=YOUR_SUPABASE_URL
37+
```
38+
Replace `YOUR_SUPABASE_API_KEY`, `YOUR_SUPABASE_SECRET`, and `YOUR_SUPABASE_URL` with values from your Supabase project dashboard.
39+
4. Sync the project with Gradle and build the app.

app/src/main/AndroidManifest.xml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
android:theme="@style/Theme.ManageProducts"
1515
tools:targetApi="31">
1616
<activity
17-
android:name=".DeepLinkHandlerActivity"
17+
android:name=".presentation.feature.deeplink.DeepLinkHandlerActivity"
1818
android:exported="true"
1919
android:theme="@style/Theme.ManageProducts" >
2020
<intent-filter android:autoVerify="true">
@@ -24,6 +24,10 @@
2424
<data
2525
android:host="supabase.com"
2626
android:scheme="app" />
27+
<data
28+
android:host="supabase.com"
29+
android:pathPrefix="/confirm"
30+
android:scheme="app" />
2731
</intent-filter>
2832
</activity>
2933
<activity

app/src/main/java/com/example/manageproducts/MainActivity.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ class MainActivity : ComponentActivity() {
2323
@Inject
2424
lateinit var supabaseClient: SupabaseClient
2525

26-
@OptIn(ExperimentalMaterial3Api::class)
2726
override fun onCreate(savedInstanceState: Bundle?) {
2827
super.onCreate(savedInstanceState)
2928
setContent {

app/src/main/java/com/example/manageproducts/data/network/dto/ProductDto.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ data class ProductDto(
1616
val image: String? = "",
1717

1818
@SerialName("id")
19-
val id: String? = "",
19+
val id: String? = null,
2020
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
package com.example.manageproducts.data.repository
22

3+
import com.example.manageproducts.domain.model.AuthState
4+
import kotlinx.coroutines.flow.StateFlow
5+
36
interface AuthenticationRepository {
7+
val authState: StateFlow<AuthState>
48
suspend fun signIn(email: String, password: String): Boolean
59
suspend fun signUp(email: String, password: String): Boolean
610
suspend fun signInWithGoogle(): Boolean
11+
suspend fun exchangeCodeForSession(code: String): Result<Unit>
12+
suspend fun verifyEmail(tokenHash: String): Result<Unit>
13+
suspend fun signOut()
714
}

app/src/main/java/com/example/manageproducts/data/repository/impl/AuthenticationRepositoryImpl.kt

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,43 @@
11
package com.example.manageproducts.data.repository.impl
22

3+
import android.util.Log
34
import com.example.manageproducts.data.repository.AuthenticationRepository
5+
import com.example.manageproducts.domain.model.AuthState
46
import io.github.jan.supabase.auth.Auth
7+
import io.github.jan.supabase.auth.OtpType
58
import io.github.jan.supabase.auth.providers.Google
69
import io.github.jan.supabase.auth.providers.builtin.Email
10+
import io.github.jan.supabase.auth.status.SessionStatus
11+
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.Dispatchers
13+
import kotlinx.coroutines.SupervisorJob
14+
import kotlinx.coroutines.flow.MutableStateFlow
15+
import kotlinx.coroutines.flow.StateFlow
16+
import kotlinx.coroutines.launch
17+
import kotlinx.datetime.TimeZone
18+
import kotlinx.datetime.toLocalDateTime
719
import javax.inject.Inject
20+
import kotlin.time.ExperimentalTime
21+
22+
private const val logTag = "AuthenticationRepository"
823

924
class AuthenticationRepositoryImpl @Inject constructor(
1025
private val auth: Auth,
1126
) : AuthenticationRepository {
27+
28+
private val _authState: MutableStateFlow<AuthState> = MutableStateFlow(AuthState.Initializing)
29+
override val authState: StateFlow<AuthState> = _authState
30+
31+
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
32+
33+
init {
34+
scope.launch {
35+
auth.sessionStatus.collect { status ->
36+
logSessionStatus(status)
37+
}
38+
}
39+
}
40+
1241
override suspend fun signIn(email: String, password: String): Boolean {
1342
return try {
1443
auth.signInWith(Email) {
@@ -23,7 +52,7 @@ class AuthenticationRepositoryImpl @Inject constructor(
2352

2453
override suspend fun signUp(email: String, password: String): Boolean {
2554
return try {
26-
auth.signUpWith(Email) {
55+
auth.signUpWith(Email, "app://supabase.com/confirm") {
2756
this.email = email
2857
this.password = password
2958
}
@@ -41,4 +70,67 @@ class AuthenticationRepositoryImpl @Inject constructor(
4170
false
4271
}
4372
}
73+
74+
75+
@OptIn(ExperimentalTime::class)
76+
private fun logSessionStatus(sessionStatus: SessionStatus) {
77+
when (sessionStatus) {
78+
is SessionStatus.Authenticated -> {
79+
Log.d(
80+
logTag, """
81+
Session source:${sessionStatus.source}
82+
SessionStatus: Authenticated
83+
Session expiry:${sessionStatus.session.expiresAt.toLocalDateTime(TimeZone.UTC)}
84+
"""
85+
)
86+
_authState.value = AuthState.Authenticated
87+
}
88+
89+
90+
SessionStatus.Initializing -> {
91+
Log.d(logTag, "SessionStatus: Initializing")
92+
_authState.value = AuthState.Initializing
93+
94+
}
95+
96+
97+
is SessionStatus.RefreshFailure -> {
98+
Log.d(logTag, "SessionStatus: RefreshFailure")
99+
}
100+
101+
is SessionStatus.NotAuthenticated -> {
102+
Log.d(
103+
logTag,
104+
"""
105+
SessionStatus: NotAuthenticated
106+
IsSignOut: ${sessionStatus.isSignOut}
107+
""".trimIndent()
108+
)
109+
_authState.value = AuthState.Unauthenticated
110+
}
111+
112+
}
113+
}
114+
115+
override suspend fun exchangeCodeForSession(code: String): Result<Unit> =
116+
runCatching {
117+
auth.exchangeCodeForSession(code = code, saveSession = true)
118+
return Result.success(Unit)
119+
}.onFailure {
120+
return Result.failure(it)
121+
}
122+
123+
override suspend fun verifyEmail(tokenHash: String): Result<Unit> = runCatching {
124+
auth.verifyEmailOtp(
125+
type = OtpType.Email.EMAIL,
126+
tokenHash = tokenHash
127+
)
128+
return Result.success(Unit)
129+
}.onFailure { e ->
130+
return Result.failure(e)
131+
}
132+
133+
override suspend fun signOut() {
134+
auth.signOut()
135+
}
44136
}

app/src/main/java/com/example/manageproducts/di/RepositoryModule.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import dagger.Binds
88
import dagger.Module
99
import dagger.hilt.InstallIn
1010
import dagger.hilt.components.SingletonComponent
11+
import javax.inject.Singleton
1112

1213
@InstallIn(SingletonComponent::class)
1314
@Module
@@ -17,6 +18,7 @@ abstract class RepositoryModule {
1718
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
1819

1920
@Binds
21+
@Singleton
2022
abstract fun bindAuthenticateRepository(impl: AuthenticationRepositoryImpl): AuthenticationRepository
2123

2224
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.example.manageproducts.domain.model
2+
3+
sealed interface AuthState {
4+
data object Initializing : AuthState
5+
data object Authenticated : AuthState
6+
data object Unauthenticated: AuthState
7+
}

app/src/main/java/com/example/manageproducts/DeepLinkHandlerActivity.kt renamed to app/src/main/java/com/example/manageproducts/presentation/feature/deeplink/DeepLinkHandlerActivity.kt

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
package com.example.manageproducts
1+
package com.example.manageproducts.presentation.feature.deeplink
22

33
import android.content.Intent
4+
import android.net.Uri
45
import android.os.Bundle
56
import android.util.Log
67
import androidx.activity.ComponentActivity
78
import androidx.activity.compose.setContent
9+
import androidx.activity.viewModels
810
import androidx.compose.foundation.layout.fillMaxSize
911
import androidx.compose.foundation.layout.padding
1012
import androidx.compose.material3.MaterialTheme
@@ -14,7 +16,10 @@ import androidx.compose.runtime.mutableStateOf
1416
import androidx.compose.runtime.remember
1517
import androidx.compose.ui.Modifier
1618
import androidx.compose.ui.unit.dp
19+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
1720
import androidx.navigation.compose.rememberNavController
21+
import com.example.manageproducts.MainActivity
22+
import com.example.manageproducts.presentation.feature.deeplink.state.RedirectDestination
1823
import com.example.manageproducts.presentation.feature.signin.SignInSuccessScreen
1924
import com.example.manageproducts.ui.theme.ManageProductsTheme
2025
import dagger.hilt.android.AndroidEntryPoint
@@ -29,6 +34,8 @@ class DeepLinkHandlerActivity : ComponentActivity() {
2934
@Inject
3035
lateinit var supabaseClient: SupabaseClient
3136

37+
private val viewModel: DeepLinkHandlerViewModel by viewModels()
38+
3239
private lateinit var callback: (String, String) -> Unit
3340

3441
@OptIn(ExperimentalTime::class)
@@ -45,6 +52,20 @@ class DeepLinkHandlerActivity : ComponentActivity() {
4552
val navController = rememberNavController()
4653
val emailState = remember { mutableStateOf("") }
4754
val createdAtState = remember { mutableStateOf("") }
55+
val state = viewModel.state.collectAsStateWithLifecycle().value
56+
LaunchedEffect(Unit) {
57+
handleDeepLink(intent)
58+
}
59+
LaunchedEffect(state.redirectDestination) {
60+
when (state.redirectDestination) {
61+
is RedirectDestination.EmailConfirmation -> {
62+
// Success
63+
navigateToMainApp()
64+
}
65+
66+
else -> Unit
67+
}
68+
}
4869
LaunchedEffect(Unit) {
4970
callback = { email, created ->
5071
emailState.value = email
@@ -74,5 +95,30 @@ class DeepLinkHandlerActivity : ComponentActivity() {
7495
}
7596
startActivity(intent)
7697
}
98+
99+
private fun handleDeepLink(intent: Intent) {
100+
if (intent.action == Intent.ACTION_VIEW) {
101+
val uri: Uri? = intent.data
102+
uri?.let {
103+
val tokenHash = it.getQueryParameter("token_hash")
104+
val code = it.getQueryParameter("code") ?: ""
105+
val actionPath = it.pathSegments.last()
106+
if (tokenHash != null) {
107+
when (actionPath) {
108+
"confirm" -> {
109+
viewModel.verifyEmailConfirmation(tokenHash)
110+
}
111+
}
112+
} else {
113+
when (actionPath) {
114+
"oauth" -> {
115+
viewModel.verifyGoogleAuth(code = code)
116+
}
117+
}
118+
println("Invalid deep link parameters")
119+
}
120+
}
121+
}
122+
}
77123
}
78124

0 commit comments

Comments
 (0)