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
6 changes: 4 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
apply plugin: 'org.jetbrains.kotlin.plugin.serialization'
apply plugin: 'com.google.devtools.ksp'
apply plugin: 'dagger.hilt.android.plugin'
apply plugin: 'com.google.firebase.crashlytics'
Expand Down Expand Up @@ -56,10 +57,11 @@ dependencies {
implementation 'androidx.credentials:credentials-play-services-auth:1.6.0'
implementation 'com.google.android.libraries.identity.googleid:googleid:1.2.0'
implementation 'com.squareup.okhttp3:okhttp:5.5.0'
implementation 'com.squareup.okhttp3:okhttp-coroutines:5.5.0'
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-kotlinx-serialization:$retrofit_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0'
implementation 'com.google.code.gson:gson:2.14.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_json_version"
implementation 'androidx.appcompat:appcompat:1.8.0'
implementation 'androidx.core:core-ktx:1.19.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.11.0'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
package com.cornellappdev.coursegrab.models

class ApiResponse<T>(val success: Boolean, val data: T, val timestamp: Long)
import kotlinx.serialization.Serializable

@Serializable
class ApiResponse<T>(val success: Boolean, val data: T, val timestamp: Long = 0L)
28 changes: 15 additions & 13 deletions app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,28 @@ package com.cornellappdev.coursegrab.models

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
@Parcelize
data class Course(
val catalog_num: Int,
val course_num: Int,
val section: String,
val instructors: List<String>,
val is_tracking: Boolean,
val status: String,
val subject_code: String,
val title: String,
val num_tracking: Int,
val mode: String,
val errors: List<String>?
@SerialName("catalog_num") val catalogNum: Int = 0,
@SerialName("course_num") val courseNum: Int = 0,
val section: String = "",
val instructors: List<String> = emptyList(),
@SerialName("is_tracking") val isTracking: Boolean = false,
val status: String = "",
@SerialName("subject_code") val subjectCode: String = "",
val title: String = "",
@SerialName("num_tracking") val numTracking: Int = 0,
val mode: String = "",
val errors: List<String>? = null
) : Parcelable {

/** The backend reports section availability through [status] rather than a flag. */
val isOpen: Boolean get() = status == STATUS_OPEN

private companion object {
const val STATUS_OPEN = "OPEN"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package com.cornellappdev.coursegrab.models

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

@Serializable
@Parcelize
data class CourseNotification(
val title: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package com.cornellappdev.coursegrab.models

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

@Serializable
@Parcelize
data class SearchContainer(
val courses: List<SearchResult>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package com.cornellappdev.coursegrab.models

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
@Parcelize
data class SearchResult(
val subject_code: String,
val course_num: Int,
@SerialName("subject_code") val subjectCode: String,
@SerialName("course_num") val courseNum: Int,
val title: String,
val sections: List<Course>
) : Parcelable
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package com.cornellappdev.coursegrab.models

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

@Serializable
@Parcelize
data class TrackingContainer(
val sections: List<Course>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.cornellappdev.coursegrab.models

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
class UserSession(
val session_token: String?,
val update_token: String?,
val session_expiration: String?
)
@SerialName("session_token") val sessionToken: String? = null,
@SerialName("update_token") val updateToken: String? = null,
@SerialName("session_expiration") val sessionExpiration: Long? = null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does backend send a JSON number instead of numeric string now?

)
Original file line number Diff line number Diff line change
Expand Up @@ -3,99 +3,76 @@ package com.cornellappdev.coursegrab.networking
import com.cornellappdev.coursegrab.data.PreferencesHelper
import com.cornellappdev.coursegrab.models.ApiResponse
import com.cornellappdev.coursegrab.models.Course
import com.cornellappdev.coursegrab.models.SearchContainer
import com.cornellappdev.coursegrab.models.SearchResult
import com.cornellappdev.coursegrab.models.TrackingContainer
import com.cornellappdev.coursegrab.models.UserSession
import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.coroutines.executeAsync
import java.lang.reflect.Type
import javax.inject.Inject
import javax.inject.Singleton

class ApiException(message: String) : Exception(message)

@Singleton
class CourseGrabRepository @Inject constructor(
private val service: CourseGrabService,
private val preferencesHelper: PreferencesHelper
) {
private val token: String
get() = preferencesHelper.sessionToken.orEmpty()
private val auth: String
get() = "Bearer ${preferencesHelper.sessionToken.orEmpty()}"

suspend fun initializeSession(googleToken: String, deviceToken: String?): Result<UserSession> =
call(Endpoint.initializeSession(googleToken, deviceToken), userSessionType)
call {
service.initializeSession(
InitializeSessionRequest(
googleToken,
deviceToken = deviceToken
)
)
}

suspend fun updateSession(updateToken: String): Result<UserSession> =
call(Endpoint.updateSession(updateToken), userSessionType)
call { service.updateSession("Bearer $updateToken") }

suspend fun getTracking(): Result<List<Course>> =
call<TrackingContainer>(Endpoint.getTracking(token), trackingType).map { it.sections }
call { service.getTracking(auth) }.map { it.sections }

suspend fun searchCourses(query: String): Result<List<SearchResult>> =
call<SearchContainer>(Endpoint.searchCourses(token, query), searchType).map { it.courses }
call { service.searchCourses(auth, SearchRequest(query)) }.map { it.courses }

suspend fun addTracking(courseId: Int): Result<Course> =
call(Endpoint.addTracking(token, courseId), courseType)
call { service.addTracking(auth, CourseIdRequest(courseId)) }

suspend fun removeTracking(courseId: Int): Result<Course> =
call(Endpoint.removeTracking(token, courseId), courseType)
call { service.removeTracking(auth, CourseIdRequest(courseId)) }

suspend fun getCourseById(courseId: Int): Result<SearchResult> =
call(Endpoint.getCourseByID(token, courseId), searchResultType)
call { service.getCourseById(auth, courseId) }

suspend fun sendDeviceToken(deviceToken: String): Result<Course> =
call(Endpoint.deviceToken(token, deviceToken), courseType)
call { service.sendDeviceToken(auth, DeviceTokenRequest(deviceToken)) }

suspend fun setNotifications(enabled: Boolean): Result<Course> =
call(Endpoint.setNotification(token, if (enabled) "ANDROID" else "NONE"), courseType)

private suspend fun <T : Any> call(endpoint: Endpoint, type: Type): Result<T> =
withContext(Dispatchers.IO) {
try {
val httpResponse = httpClient.newCall(endpoint.okHttpRequest()).executeAsync()
val body = httpResponse.use { it.body.string() }

val envelope: ApiResponse<T>? = try {
gson.fromJson(body, type)
} catch (_: JsonParseException) {
null
}

val data = envelope?.data
when {
envelope == null || data == null -> Result.failure(
ApiException(
if (httpResponse.isSuccessful) "Unreadable response from server"
else "HTTP error (${httpResponse.code})"
)
)
call {
service.setNotifications(
auth,
NotificationRequest(if (enabled) "ANDROID" else "NONE")
)
}

!envelope.success -> Result.failure(
ApiException((data as? Course)?.errors?.firstOrNull() ?: "Request failed")
private suspend fun <T : Any> call(request: suspend () -> ApiResponse<T>): Result<T> =
try {
val envelope = request()
if (envelope.success) {
Result.success(envelope.data)
} else {
Result.failure(
ApiException(
(envelope.data as? Course)?.errors?.firstOrNull() ?: "Request failed"
)

else -> Result.success(data)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}

private companion object {
val httpClient = OkHttpClient()
val gson = Gson()
val userSessionType: Type = object : TypeToken<ApiResponse<UserSession>>() {}.type
val trackingType: Type = object : TypeToken<ApiResponse<TrackingContainer>>() {}.type
val searchType: Type = object : TypeToken<ApiResponse<SearchContainer>>() {}.type
val searchResultType: Type = object : TypeToken<ApiResponse<SearchResult>>() {}.type
val courseType: Type = object : TypeToken<ApiResponse<Course>>() {}.type
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.cornellappdev.coursegrab.networking

import com.cornellappdev.coursegrab.models.ApiResponse
import com.cornellappdev.coursegrab.models.Course
import com.cornellappdev.coursegrab.models.SearchContainer
import com.cornellappdev.coursegrab.models.SearchResult
import com.cornellappdev.coursegrab.models.TrackingContainer
import com.cornellappdev.coursegrab.models.UserSession
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path

interface CourseGrabService {

@POST("session/initialize/")
suspend fun initializeSession(
@Body body: InitializeSessionRequest
): ApiResponse<UserSession>

@POST("session/update/")
suspend fun updateSession(
@Header("Authorization") auth: String
): ApiResponse<UserSession>

@GET("users/tracking/")
suspend fun getTracking(
@Header("Authorization") auth: String
): ApiResponse<TrackingContainer>

@POST("courses/search/")
suspend fun searchCourses(
@Header("Authorization") auth: String,
@Body body: SearchRequest
): ApiResponse<SearchContainer>

@GET("courses/{courseId}/")
suspend fun getCourseById(
@Header("Authorization") auth: String,
@Path("courseId") courseId: Int
): ApiResponse<SearchResult>

@POST("sections/track/")
suspend fun addTracking(
@Header("Authorization") auth: String,
@Body body: CourseIdRequest
): ApiResponse<Course>

@POST("sections/untrack/")
suspend fun removeTracking(
@Header("Authorization") auth: String,
@Body body: CourseIdRequest
): ApiResponse<Course>

@POST("users/device-token/")
suspend fun sendDeviceToken(
@Header("Authorization") auth: String,
@Body body: DeviceTokenRequest
): ApiResponse<Course>

@POST("users/notification/")
suspend fun setNotifications(
@Header("Authorization") auth: String,
@Body body: NotificationRequest
): ApiResponse<Course>
}

@Serializable
data class InitializeSessionRequest(
val token: String,
@SerialName("device_type") val deviceType: String = "ANDROID",
@SerialName("device_token") val deviceToken: String? = null
)

@Serializable
data class SearchRequest(val query: String)

@Serializable
data class CourseIdRequest(@SerialName("course_id") val courseId: Int)

@Serializable
data class DeviceTokenRequest(@SerialName("device_token") val deviceToken: String)

@Serializable
data class NotificationRequest(val notification: String)
Loading