Skip to content

Commit

Permalink
Built Tanga subscription feature (#95)
Browse files Browse the repository at this point in the history
* Built the subscription feature - users can now subscribe to one the two paid plans for Tanga

* Fixing detekt issues

* Fixing unit tests

* Started adding unit tests

* Minor improvements
  • Loading branch information
rygelouv authored May 26, 2024
1 parent 454d245 commit 0863e73
Show file tree
Hide file tree
Showing 43 changed files with 1,223 additions and 107 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,9 @@ GoogleService-Info.plist

# Sentry Auth Token
sentry.properties
# Keystore properties file
keystore.properties
# Keystore file
tangakeystore.jks
# Secret Keys
secrets.properties
36 changes: 32 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.util.Properties

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
Expand All @@ -12,6 +14,16 @@ plugins {

apply(from = "${project.rootDir}/buildscripts/jacoco.gradle.kts")

val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties().apply {
load(keystorePropertiesFile.inputStream())
}

val secretPropertiesFile = rootProject.file("secrets.properties")
val secretProperties = Properties().apply {
load(secretPropertiesFile.inputStream())
}

android {
namespace = "app.books.tanga"
compileSdk = 34
Expand All @@ -20,7 +32,7 @@ android {
applicationId = "app.books.tanga"
minSdk = 24
targetSdk = 34
versionCode = 1
versionCode = 5
versionName = "0.5.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Expand All @@ -29,22 +41,34 @@ android {
}
}

signingConfigs {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"].toString())
storePassword = keystoreProperties["storePassword"].toString()
keyAlias = keystoreProperties["keyAlias"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
}
}

testOptions {
unitTests.isReturnDefaultValues = true
}

buildTypes {
debug {
buildConfigField("String", "REVENUECAT_API_KEY", "\"${secretProperties["revenueCatApiKey"]}\"")
enableUnitTestCoverage = true
enableAndroidTestCoverage = true
}
release {
isMinifyEnabled = false
buildConfigField("String", "REVENUECAT_API_KEY", "\"${secretProperties["revenueCatApiKey"]}\"")
isMinifyEnabled = false // Will change later
isDebuggable = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}

Expand Down Expand Up @@ -119,8 +143,12 @@ dependencies {
// Google Play Services
implementation(libs.android.gms.play.services.auth)

// Media and Logging
implementation(libs.revenuecat.purchases)

// Media
implementation(libs.media3.exoplayer)

// Logging
implementation(libs.timber)

// Kotlin Immutable Collections
Expand Down
2 changes: 0 additions & 2 deletions app/src/androidTest/java/app/books/tanga/fixtures/Fixtures.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ object Fixtures {
fullName = "John Doe",
email = "john.doe@example.com",
photoUrl = "https://example.com/johndoe.jpg",
isPro = true,
createdAt = Date()
)

Expand All @@ -78,7 +77,6 @@ object Fixtures {
fullName = "Mansa Musa",
email = "some@mail.com",
photoUrl = "https://someurl.com",
isPro = false,
createdAt = Timestamp(1697806449, 0).toDate()
)

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/app/books/tanga/TangaApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import app.books.tanga.di.TimberTrees
import app.books.tanga.di.plantAll
import app.books.tanga.errors.TangaErrorTracker
import app.books.tanga.revenuecat.RevenueCatInitializer
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

Expand All @@ -15,12 +16,17 @@ class TangaApp : Application() {
@Inject
lateinit var errorTracker: TangaErrorTracker

@Inject
lateinit var revenueCatInitializer: RevenueCatInitializer

override fun onCreate() {
super.onCreate()
// Plant all the Timber trees added to Timber
timberTrees.plantAll()

// Initialize the error tracker
errorTracker.init()

revenueCatInitializer.initialize(this)
}
}
11 changes: 5 additions & 6 deletions app/src/main/java/app/books/tanga/data/user/UserMapper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ fun FirebaseUser.toAnonymousUser(): User =
fullName = "Anonymous",
email = "",
photoUrl = null,
isPro = false,
isAnonymous = true,
createdAt = metadata?.creationTimestamp?.let { Date(it) } ?: Date()
)
Expand All @@ -25,8 +24,7 @@ fun FirebaseUser.toUser(): User = User(
fullName = requireNotNull(displayName) { "User must have a display name" },
email = requireNotNull(email) { "User must have an email" },
photoUrl = photoUrl?.toString(),
isPro = false,
createdAt = metadata?.creationTimestamp?.let { Date(it) } ?: Date()
createdAt = metadata?.creationTimestamp?.let { Date(it) } ?: Date(),
)

fun User.toFireStoreUserData() =
Expand All @@ -36,7 +34,8 @@ fun User.toFireStoreUserData() =
FirestoreDatabase.Users.Fields.EMAIL to email,
FirestoreDatabase.Users.Fields.PHOTO_URL to photoUrl,
// We use the server timestamp to avoid issues with the device time
FirestoreDatabase.Users.Fields.CREATED_AT to FieldValue.serverTimestamp()
FirestoreDatabase.Users.Fields.CREATED_AT to FieldValue.serverTimestamp(),
FirestoreDatabase.Users.Fields.SUBSCRIBED_AT to subscribedAt?.let { Timestamp(it) }
)

fun FirestoreData.toUser(uid: String) =
Expand All @@ -45,6 +44,6 @@ fun FirestoreData.toUser(uid: String) =
fullName = this[FirestoreDatabase.Users.Fields.FULL_NAME].toString(),
email = this[FirestoreDatabase.Users.Fields.EMAIL].toString(),
photoUrl = this[FirestoreDatabase.Users.Fields.PHOTO_URL].toString(),
isPro = false,
createdAt = (this[FirestoreDatabase.Users.Fields.CREATED_AT] as Timestamp).toDate()
createdAt = (this[FirestoreDatabase.Users.Fields.CREATED_AT] as? Timestamp)?.toDate(),
subscribedAt = (this[FirestoreDatabase.Users.Fields.SUBSCRIBED_AT] as? Timestamp)?.toDate()
)
43 changes: 43 additions & 0 deletions app/src/main/java/app/books/tanga/data/user/UserRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,27 @@ import app.books.tanga.firestore.FirestoreOperationHandler
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.CollectionReference
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ktx.snapshots
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.tasks.await

interface UserRepository {
suspend fun getUser(): Result<User?>

suspend fun getUserStream(): Flow<User?>

suspend fun getUserId(): UserId?

suspend fun createUser(user: User): Result<Unit>

suspend fun deleteUser(user: User): Result<Unit>

suspend fun updateUser(user: User): Result<Unit>
}

val FirebaseFirestore.userCollection: CollectionReference
Expand All @@ -31,6 +40,7 @@ class UserRepositoryImpl @Inject constructor(
private val operationHandler: FirestoreOperationHandler,
private val firebaseAuth: FirebaseAuth
) : UserRepository, FirestoreOperationHandler by operationHandler {

override suspend fun getUser(): Result<User?> {
val sessionId = prefDataStoreRepo.getSessionId().first()
val currentUser = firebaseAuth.currentUser
Expand All @@ -57,6 +67,28 @@ class UserRepositoryImpl @Inject constructor(
return result
}

override suspend fun getUserStream(): Flow<User?> = flow {
val sessionId = prefDataStoreRepo.getSessionId().first()
val currentUser = firebaseAuth.currentUser

if (sessionId != null) {
if (currentUser?.isAnonymous == true) {
emit(currentUser.toAnonymousUser())
} else {
val uid = sessionId.value
val stream = firestore.userCollection
.document(uid)
.snapshots().map { documentSnapshot ->
val userDataMap = documentSnapshot.data
userDataMap?.toUser(uid)
}
emitAll(stream)
}
} else {
emit(null)
}
}

override suspend fun getUserId(): UserId? {
val sessionId = prefDataStoreRepo.getSessionId().first()
return sessionId?.value?.let { UserId(it) }
Expand All @@ -73,6 +105,17 @@ class UserRepositoryImpl @Inject constructor(
}
}

override suspend fun updateUser(user: User): Result<Unit> {
val userMap = user.toFireStoreUserData()
return executeOperation {
firestore
.userCollection
.document(user.id.value)
.update(userMap)
.await()
}
}

override suspend fun deleteUser(user: User): Result<Unit> =
executeOperation {
firestore
Expand Down
40 changes: 40 additions & 0 deletions app/src/main/java/app/books/tanga/entity/Subscription.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package app.books.tanga.entity

import java.util.Date

/**
* Represents a subscription plan that a user can purchase.
* @property identifier A unique identifier for the subscription plan.
* @property productId The Store product ID of the subscription plan.
* @property type The type of the subscription plan (monthly or yearly).
* @property price The price of the subscription plan.
*/
data class SubscriptionPlan(
val identifier: String,
val productId: String,
val type: SubscriptionType,
val price: Price,
)

/**
* Represents the type of a subscription plan.
*/
enum class SubscriptionType {
MONTHLY,
YEARLY
}

/**
* Represents the price of a subscription plan.
* @property formattedValue The formatted value of the price.
* @property currency The currency in which the price is displayed.
*/
data class Price(
val formattedValue: String,
val currency: String,
)

data class SubscriberInfo(
val hasActiveSubscription: Boolean,
val expirationDate: Date?,
)
4 changes: 2 additions & 2 deletions app/src/main/java/app/books/tanga/entity/User.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ data class User(
val fullName: String,
val email: String,
val photoUrl: String?,
val isPro: Boolean,
val isAnonymous: Boolean = false,
val createdAt: Date
val createdAt: Date?,
val subscribedAt: Date? = null
) {
val firsName: String
get() = fullName.split(" ").firstOrNull() ?: fullName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ class AnonymousAuthServiceImpl @Inject constructor(
fullName = requireNotNull(displayName) { "User must have a display name" },
email = requireNotNull(email) { "User must have an email" },
photoUrl = photoUrl,
isPro = false,
isAnonymous = false,
createdAt = creationDate
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import app.books.tanga.errors.TangaErrorTracker
import app.books.tanga.errors.toUiError
import com.google.android.gms.auth.api.identity.SignInClient
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.Date
import javax.inject.Inject
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
Expand Down Expand Up @@ -54,7 +55,8 @@ class AuthViewModel @Inject constructor(
postEvent(AuthUiEvent.NavigateTo.ToHomeScreen)
errorTracker.setUserDetails(
userId = user.id,
userCreationDate = user.createdAt
// TODO remove nullability
userCreationDate = user.createdAt ?: Date()
)
}.onFailure { error ->
_state.update { it.copy(skipProgressState = ProgressState.Hide) }
Expand All @@ -74,7 +76,8 @@ class AuthViewModel @Inject constructor(
_state.update { it.copy(googleSignInButtonProgressState = ProgressState.Hide) }
errorTracker.setUserDetails(
userId = user.id,
userCreationDate = user.createdAt
// TODO remove nullability
userCreationDate = user.createdAt ?: Date()
)
}.onFailure { error ->
Timber.e("Complete Google sign In failure", error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ package app.books.tanga.feature.auth
import app.books.tanga.data.user.UserRepository
import app.books.tanga.entity.User
import app.books.tanga.errors.DomainError
import app.books.tanga.session.SessionId
import app.books.tanga.revenuecat.RevenueCatAuthenticator
import app.books.tanga.session.SessionManager
import app.books.tanga.session.SessionState
import app.books.tanga.session.toSessionId
import app.books.tanga.utils.resultOf
import com.google.android.gms.auth.api.identity.BeginSignInResult
import com.google.android.gms.auth.api.identity.SignInCredential
Expand All @@ -24,7 +25,8 @@ class AuthenticationInteractor @Inject constructor(
private val userRepository: UserRepository,
private val sessionManager: SessionManager,
private val googleAuthService: GoogleAuthService,
private val anonymousAuthService: AnonymousAuthService
private val anonymousAuthService: AnonymousAuthService,
private val revenueCatAuthenticator: RevenueCatAuthenticator
) {

fun isUserAnonymous(): Boolean = anonymousAuthService.isUserAnonymous()
Expand Down Expand Up @@ -61,17 +63,20 @@ class AuthenticationInteractor @Inject constructor(

val user = authResult.user
userRepository.createUser(user)
val sessionId = SessionId(user.id.value)
revenueCatAuthenticator.logIn(user.id)
val sessionId = user.id.toSessionId()
sessionManager.openSession(sessionId)
user
}.onFailure {
Timber.e(it, "Google sign in failed")
return Result.failure(DomainError.UnableToSignInWithGoogleError(it))
}

suspend fun signOut(): Result<SessionState> =
resultOf {
googleAuthService.signOut()
sessionManager.closeSession()
revenueCatAuthenticator.logOut()
SessionState.SignedOut
}

Expand Down
Loading

0 comments on commit 0863e73

Please sign in to comment.