Skip to content
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ app/google-services.json
secrets.properties

# Mac
.DS_store
.DS_store
.kotlin/
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,17 @@ Score is Cornell’s all-in-one sports discovery hub that makes Cornell sports e
- `secrets.properties`
4. Add `secrets.properties` to the project root and `google-services.json` to the app level in Android Studio
5. Build and run!

## Authentication

Google sign-in uses Credential Manager and Firebase Authentication, then exchanges the Firebase ID token for Score backend tokens. Enable Google as a sign-in provider in Firebase, register the Android signing SHA-1 and SHA-256 fingerprints, and install the updated `app/google-services.json`. The file must include the web OAuth client so the Google Services plugin generates `default_web_client_id`. The backend Firebase Admin configuration must use the same Firebase project.

The backend must support `loginUser(idToken:)`, `signupUser(idToken:, name:)`, `refreshAccessToken`, and `logoutUser`. The Android schema matches the local backend's token-only responses. It does not require the user and favorite-team response fields from the iOS PR. Cornell email filtering is also required on the backend to enforce the restriction outside this client.

The welcome screen's Sign Up and Log In buttons both start Google sign-in. The app first calls `loginUser`; only a missing-user response triggers `signupUser`. Successful authentication enters the existing app, and Sign Up Later keeps guest browsing available.

`AuthRepository.state` records the backend session. A Firebase session alone does not sign the user into Score. Backend sessions are encrypted using Android Keystore and stored outside backups. Session restoration refreshes expired access tokens; network failures preserve the saved session, while rejected refresh tokens clear it.

Auth is currently limited to the welcome flow. Profile screens, profile and sports onboarding, and authorization headers on the app's existing API client are not connected to it.

Run `./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug` with Java 17 to verify the app. End-to-end Google sign-in needs a device or emulator with Google Play services and a backend configured for the updated Firebase project.
53 changes: 23 additions & 30 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ plugins {
id("com.apollographql.apollo") version "4.0.0"
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" // this version matches your Kotlin version
id("org.jetbrains.kotlin.plugin.compose") version
"2.0.0" // this version matches your Kotlin version
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.gms.google-services")
}


android {
namespace = "com.cornellappdev.score"
compileSdk = 35
Expand All @@ -38,46 +38,30 @@ android {
}
buildTypes {
debug {
buildConfigField(
"String",
"BASE_URL",
"\"${secrets.getProperty("API_URL_DEV")}\""
)
buildConfigField(
"String",
"SOCKET_URL",
"\"${secrets.getProperty("SOCKET_URL_DEV")}\""
)
buildConfigField("String", "BASE_URL", "\"${secrets.getProperty("API_URL_DEV")}\"")
buildConfigField("String", "SOCKET_URL", "\"${secrets.getProperty("SOCKET_URL_DEV")}\"")
}
release {
buildConfigField(
"String",
"BASE_URL",
"\"${secrets.getProperty("API_URL_PROD")}\""
)
buildConfigField("String", "BASE_URL", "\"${secrets.getProperty("API_URL_PROD")}\"")
buildConfigField(
"String",
"SOCKET_URL",
"\"${secrets.getProperty("SOCKET_URL_PROD")}\""
"\"${secrets.getProperty("SOCKET_URL_PROD")}\"",
)
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
"proguard-rules.pro",
)
}
}
composeOptions {
kotlinCompilerExtensionVersion = "1.7.0"
}
composeOptions { kotlinCompilerExtensionVersion = "1.7.0" }

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
kotlinOptions { jvmTarget = "1.8" }
}

dependencies {
Expand Down Expand Up @@ -118,16 +102,25 @@ dependencies {
lintChecks(libs.compose.lint.checks)
implementation(platform("com.google.firebase:firebase-bom:34.3.0"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-auth")
implementation("androidx.credentials:credentials:1.5.0")
implementation("androidx.credentials:credentials-play-services-auth:1.5.0")
implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
}

apollo {
service("service") {
packageName.set("com.example.score")
secrets.getProperty("API_URL_DEV")?.takeIf { it.isNotBlank() }?.let { apiUrl ->
introspection {
endpointUrl.set(apiUrl)
schemaFile.set(file("src/main/graphql/schema.graphqls"))
secrets
.getProperty("API_URL_DEV")
?.takeIf { it.isNotBlank() }
?.let { apiUrl ->
introspection {
endpointUrl.set(apiUrl)
schemaFile.set(file("src/main/graphql/schema.graphqls"))
}
}
}
}
}
25 changes: 25 additions & 0 deletions app/src/main/graphql/Auth.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
mutation LoginUser($idToken: String!) {
loginUser(idToken: $idToken) {
accessToken
refreshToken
}
}

mutation SignupUser($idToken: String!, $name: String) {
signupUser(idToken: $idToken, name: $name) {
accessToken
refreshToken
}
}

mutation RefreshAccessToken {
refreshAccessToken {
newAccessToken
}
}

mutation LogoutUser {
logoutUser {
success
}
}
8 changes: 4 additions & 4 deletions app/src/main/graphql/schema.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,14 @@ type Mutation {
createArticle(image: String, publishedAt: String!, slug: String!, sportsType: String!, title: String!, url: String!): CreateArticle

"""
Login by net_id; returns access_token and refresh_token.
Login with Firebase ID token; returns access_token and refresh_token.
"""
loginUser("User's net ID (e.g. Cornell netid)." netId: String!): LoginUser
loginUser("Firebase ID token from the client." idToken: String!): LoginUser

"""
Create a new user by net_id; returns access_token and refresh_token (no separate login needed).
Create a new user with Firebase ID token; returns access_token and refresh_token.
"""
signupUser("Email address." email: String, "Display name." name: String, "User's net ID (e.g. Cornell netid)." netId: String!): SignupUser
signupUser("Email address." email: String, "Display name." name: String, "Firebase ID token from the client." idToken: String!): SignupUser

"""
Exchange a valid refresh token (in Authorization header) for a new access_token.
Expand Down
91 changes: 10 additions & 81 deletions app/src/main/java/com/cornellappdev/score/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,101 +3,30 @@ package com.cornellappdev.score
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.core.content.edit
import com.cornellappdev.score.auth.AuthViewModel
import com.cornellappdev.score.model.OnboardingPreferencesRepository
import com.cornellappdev.score.nav.root.RootNavigation
import com.cornellappdev.score.screen.FavoriteSport
import com.cornellappdev.score.screen.OnboardingFavoriteSportsScreen
import com.cornellappdev.score.screen.OnboardingProfileSelectScreen
import com.cornellappdev.score.screen.OnboardingScreen
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

private const val APP_PREFERENCES = "score_app_preferences"
private const val KEY_ONBOARDING_PROFILE_PICTURE = "onboarding_profile_picture"
private const val KEY_ONBOARDING_FAVORITE_SPORTS = "onboarding_favorite_sports"

private lateinit var firebaseAnalytics: FirebaseAnalytics

private enum class OnboardingStep {
WELCOME,
PROFILE_SELECT,
SPORT_SELECT,
COMPLETE
}

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var onboardingPreferencesRepository: OnboardingPreferencesRepository
@Inject lateinit var onboardingPreferences: OnboardingPreferencesRepository
private val authViewModel: AuthViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()

val onboardingCompleted = onboardingPreferencesRepository.isOnboardingCompleted()
val preferences = getSharedPreferences(APP_PREFERENCES, MODE_PRIVATE)
val savedFavoriteSportKeys = preferences
.getStringSet(KEY_ONBOARDING_FAVORITE_SPORTS, emptySet())
.orEmpty()
.joinToString("|")

Firebase.analytics
setContent {
var onboardingStep by rememberSaveable {
mutableStateOf(
if (onboardingCompleted) OnboardingStep.COMPLETE else OnboardingStep.WELCOME
)
}

val completeOnboarding = remember {
{
onboardingPreferencesRepository.markOnboardingCompleted()
onboardingStep = OnboardingStep.COMPLETE
}
}

when (onboardingStep) {
OnboardingStep.WELCOME -> OnboardingScreen(
onSignUp = { onboardingStep = OnboardingStep.PROFILE_SELECT },
onLogIn = completeOnboarding,
onLater = completeOnboarding
)

OnboardingStep.PROFILE_SELECT -> OnboardingProfileSelectScreen(
onNext = { selectedPicture ->
preferences.edit {
putString(KEY_ONBOARDING_PROFILE_PICTURE, selectedPicture.name)
}
onboardingStep = OnboardingStep.SPORT_SELECT
}
)

OnboardingStep.SPORT_SELECT -> OnboardingFavoriteSportsScreen(
initiallySelectedKeys = savedFavoriteSportKeys,
onFinish = { selectedSports ->
preferences.edit {
putStringSet(
KEY_ONBOARDING_FAVORITE_SPORTS,
selectedSports.map(FavoriteSport::storageKey).toSet()
)
}
completeOnboarding()
}
)

OnboardingStep.COMPLETE -> RootNavigation()
}
ScoreApp(
authViewModel = authViewModel,
onboardingPreferences = onboardingPreferences,
onSignIn = { authViewModel.signIn(this) },
)
}

firebaseAnalytics = Firebase.analytics
}
}
65 changes: 65 additions & 0 deletions app/src/main/java/com/cornellappdev/score/ScoreApp.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.cornellappdev.score

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.cornellappdev.score.auth.AuthState
import com.cornellappdev.score.auth.AuthStatusOverlay
import com.cornellappdev.score.auth.AuthViewModel
import com.cornellappdev.score.model.OnboardingPreferencesRepository
import com.cornellappdev.score.nav.root.RootNavigation
import com.cornellappdev.score.screen.OnboardingScreen
import com.cornellappdev.score.theme.CrimsonPrimary

@Composable
fun ScoreApp(
authViewModel: AuthViewModel,
onboardingPreferences: OnboardingPreferencesRepository,
onSignIn: () -> Unit,
modifier: Modifier = Modifier,
) {
val authState by authViewModel.authState.collectAsStateWithLifecycle()
val signInState by authViewModel.signInState.collectAsStateWithLifecycle()
var skippedWelcome by rememberSaveable {
mutableStateOf(onboardingPreferences.isOnboardingCompleted())
}
val signedIn = authState is AuthState.SignedIn

LaunchedEffect(signedIn) {
if (signedIn) {
onboardingPreferences.markOnboardingCompleted()
skippedWelcome = true
}
}

Box(modifier.fillMaxSize().background(Color.White)) {
when {
authState == AuthState.Loading ->
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = CrimsonPrimary)
}
signedIn || skippedWelcome -> RootNavigation()
else ->
OnboardingScreen(
onSignUp = onSignIn,
onLogIn = onSignIn,
onLater = {
onboardingPreferences.markOnboardingCompleted()
skippedWelcome = true
},
)
}
AuthStatusOverlay(signInState, authViewModel::dismissError)
}
}
Loading
Loading