Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Built Tanga subscription feature #95

Merged
merged 5 commits into from
May 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading