From 671d4e0c3cd93be2227489592ec90687a32138b4 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 17:09:58 -0400 Subject: [PATCH 1/3] Migrate to Retrofit --- app/build.gradle | 6 +- .../coursegrab/models/ApiResponse.kt | 5 +- .../cornellappdev/coursegrab/models/Course.kt | 30 +++-- .../coursegrab/models/CourseNotification.kt | 2 + .../coursegrab/models/SearchContainer.kt | 2 + .../coursegrab/models/SearchResult.kt | 2 + .../coursegrab/models/TrackingContainer.kt | 2 + .../coursegrab/models/UserSession.kt | 11 +- .../networking/CourseGrabRepository.kt | 107 ++++++++---------- .../networking/CourseGrabService.kt | 95 ++++++++++++++++ .../coursegrab/networking/Endpoint.kt | 44 ------- .../coursegrab/networking/NetworkModule.kt | 43 +++++++ .../coursegrab/networking/UserEndpoints.kt | 90 --------------- .../coursegrab/service/NotificationService.kt | 29 +++-- .../coursegrab/ui/login/LoginViewModel.kt | 4 +- build.gradle | 3 + 16 files changed, 249 insertions(+), 226 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt delete mode 100644 app/src/main/java/com/cornellappdev/coursegrab/networking/Endpoint.kt create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/networking/NetworkModule.kt delete mode 100644 app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt diff --git a/app/build.gradle b/app/build.gradle index eb0a966..6f0a2d0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' @@ -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' diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/ApiResponse.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/ApiResponse.kt index b742c1d..ff11113 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/ApiResponse.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/ApiResponse.kt @@ -1,3 +1,6 @@ package com.cornellappdev.coursegrab.models -class ApiResponse(val success: Boolean, val data: T, val timestamp: Long) +import kotlinx.serialization.Serializable + +@Serializable +class ApiResponse(val success: Boolean, val data: T, val timestamp: Long = 0L) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt index 59c35e1..1c807d4 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt @@ -2,20 +2,26 @@ package com.cornellappdev.coursegrab.models import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable +/** + * Also carries failure payloads, where only [errors] is present — hence the defaults on + * every other property. + */ +@Serializable @Parcelize data class Course( - val catalog_num: Int, - val course_num: Int, - val section: String, - val instructors: List, - val is_tracking: Boolean, - val status: String, - val subject_code: String, - val title: String, - val num_tracking: Int, - val mode: String, - val errors: List? + val catalog_num: Int = 0, + val course_num: Int = 0, + val section: String = "", + val instructors: List = emptyList(), + val is_tracking: Boolean = false, + val status: String = "", + val subject_code: String = "", + val title: String = "", + val num_tracking: Int = 0, + val mode: String = "", + val errors: List? = null ) : Parcelable { /** The backend reports section availability through [status] rather than a flag. */ @@ -24,4 +30,4 @@ data class Course( private companion object { const val STATUS_OPEN = "OPEN" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/CourseNotification.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/CourseNotification.kt index 094b2c9..66c7d9a 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/CourseNotification.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/CourseNotification.kt @@ -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, diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/SearchContainer.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/SearchContainer.kt index 0f9ac7c..997f103 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/SearchContainer.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/SearchContainer.kt @@ -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, diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/SearchResult.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/SearchResult.kt index 0585e2f..c91423f 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/SearchResult.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/SearchResult.kt @@ -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 SearchResult( val subject_code: String, diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/TrackingContainer.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/TrackingContainer.kt index f7d3d5d..9550050 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/TrackingContainer.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/TrackingContainer.kt @@ -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 diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/UserSession.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/UserSession.kt index 6f65d12..5bfdda3 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/UserSession.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/UserSession.kt @@ -1,7 +1,10 @@ package com.cornellappdev.coursegrab.models +import kotlinx.serialization.Serializable + +@Serializable class UserSession( - val session_token: String?, - val update_token: String?, - val session_expiration: String? -) \ No newline at end of file + val session_token: String? = null, + val update_token: String? = null, + val session_expiration: Long? = null +) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt index 1920397..beac98d 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt @@ -3,19 +3,9 @@ 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 @@ -23,79 +13,74 @@ 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 = - call(Endpoint.initializeSession(googleToken, deviceToken), userSessionType) + call { + service.initializeSession( + InitializeSessionRequest( + googleToken, + device_token = deviceToken + ) + ) + } suspend fun updateSession(updateToken: String): Result = - call(Endpoint.updateSession(updateToken), userSessionType) + call { service.updateSession("Bearer $updateToken") } suspend fun getTracking(): Result> = - call(Endpoint.getTracking(token), trackingType).map { it.sections } + call { service.getTracking(auth) }.map { it.sections } suspend fun searchCourses(query: String): Result> = - call(Endpoint.searchCourses(token, query), searchType).map { it.courses } + call { service.searchCourses(auth, SearchRequest(query)) }.map { it.courses } suspend fun addTracking(courseId: Int): Result = - call(Endpoint.addTracking(token, courseId), courseType) + call { service.addTracking(auth, CourseIdRequest(courseId)) } suspend fun removeTracking(courseId: Int): Result = - call(Endpoint.removeTracking(token, courseId), courseType) + call { service.removeTracking(auth, CourseIdRequest(courseId)) } suspend fun getCourseById(courseId: Int): Result = - call(Endpoint.getCourseByID(token, courseId), searchResultType) + call { service.getCourseById(auth, courseId) } suspend fun sendDeviceToken(deviceToken: String): Result = - call(Endpoint.deviceToken(token, deviceToken), courseType) + call { service.sendDeviceToken(auth, DeviceTokenRequest(deviceToken)) } suspend fun setNotifications(enabled: Boolean): Result = - call(Endpoint.setNotification(token, if (enabled) "ANDROID" else "NONE"), courseType) - - private suspend fun call(endpoint: Endpoint, type: Type): Result = - withContext(Dispatchers.IO) { - try { - val httpResponse = httpClient.newCall(endpoint.okHttpRequest()).executeAsync() - val body = httpResponse.use { it.body.string() } - - val envelope: ApiResponse? = 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") + /** + * Unwraps the [ApiResponse] envelope. Retrofit dispatches off the caller's thread and + * throws on transport, HTTP, and decoding failures, so all this adds is turning the + * envelope's own `success` flag into a failure. + */ + private suspend fun call(request: suspend () -> ApiResponse): Result = + try { + val envelope = request() + if (envelope.success) { + Result.success(envelope.data) + } else { + // `errors` only exists on Course payloads; other endpoints report failure + // through the flag alone. + 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) { + // Never swallow cancellation — the caller's scope is shutting down. + throw e + } catch (e: Exception) { + Result.failure(e) } - - private companion object { - val httpClient = OkHttpClient() - val gson = Gson() - val userSessionType: Type = object : TypeToken>() {}.type - val trackingType: Type = object : TypeToken>() {}.type - val searchType: Type = object : TypeToken>() {}.type - val searchResultType: Type = object : TypeToken>() {}.type - val courseType: Type = object : TypeToken>() {}.type - } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt new file mode 100644 index 0000000..5a0288c --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt @@ -0,0 +1,95 @@ +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.Serializable +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path + +/** + * The Authorization header is per-endpoint rather than an interceptor: `initializeSession` + * sends none, and `updateSession` sends the update token instead of the session token. + */ +interface CourseGrabService { + + @POST("session/initialize/") + suspend fun initializeSession( + @Body body: InitializeSessionRequest + ): ApiResponse + + @POST("session/update/") + suspend fun updateSession( + @Header("Authorization") auth: String, + @Body body: EmptyRequest = EmptyRequest() + ): ApiResponse + + @GET("users/tracking/") + suspend fun getTracking( + @Header("Authorization") auth: String + ): ApiResponse + + @POST("courses/search/") + suspend fun searchCourses( + @Header("Authorization") auth: String, + @Body body: SearchRequest + ): ApiResponse + + @GET("courses/{courseId}/") + suspend fun getCourseById( + @Header("Authorization") auth: String, + @Path("courseId") courseId: Int + ): ApiResponse + + @POST("sections/track/") + suspend fun addTracking( + @Header("Authorization") auth: String, + @Body body: CourseIdRequest + ): ApiResponse + + @POST("sections/untrack/") + suspend fun removeTracking( + @Header("Authorization") auth: String, + @Body body: CourseIdRequest + ): ApiResponse + + @POST("users/device-token/") + suspend fun sendDeviceToken( + @Header("Authorization") auth: String, + @Body body: DeviceTokenRequest + ): ApiResponse + + @POST("users/notification/") + suspend fun setNotifications( + @Header("Authorization") auth: String, + @Body body: NotificationRequest + ): ApiResponse +} + +@Serializable +class EmptyRequest + +@Serializable +data class InitializeSessionRequest( + val token: String, + val device_type: String = "ANDROID", + val device_token: String? = null +) + +@Serializable +data class SearchRequest(val query: String) + +@Serializable +data class CourseIdRequest(val course_id: Int) + +@Serializable +data class DeviceTokenRequest(val device_token: String) + +@Serializable +data class NotificationRequest(val notification: String) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/Endpoint.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/Endpoint.kt deleted file mode 100644 index 24a1445..0000000 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/Endpoint.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.cornellappdev.coursegrab.networking - -import com.cornellappdev.coursegrab.BuildConfig -import okhttp3.Headers.Companion.toHeaders -import okhttp3.Request -import okhttp3.RequestBody - -enum class EndpointMethod { - GET, POST -} - -class Endpoint( - private val path: String, - private val headers: Map = mapOf(), - private val body: RequestBody? = null, - private val method: EndpointMethod -) { - - private val host = "https://" + BuildConfig.BACKEND_URI + "/api" - - companion object - - fun okHttpRequest(): Request { - val endpoint = host + path - val headers = headers.toHeaders() - - when (method) { - EndpointMethod.POST -> { - return Request.Builder() - .url(endpoint) - .post(body!!) - .headers(headers) - .build() - } - EndpointMethod.GET -> { - return Request.Builder() - .url(endpoint) - .headers(headers) - .get() - .build() - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/NetworkModule.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/NetworkModule.kt new file mode 100644 index 0000000..4b568ed --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/NetworkModule.kt @@ -0,0 +1,43 @@ +package com.cornellappdev.coursegrab.networking + +import com.cornellappdev.coursegrab.BuildConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideJson(): Json = Json { + // The backend may add fields we don't model; that shouldn't fail a response. + ignoreUnknownKeys = true + encodeDefaults = true + } + + @Provides + @Singleton + fun provideHttpClient(): OkHttpClient = OkHttpClient() + + @Provides + @Singleton + fun provideRetrofit(json: Json, client: OkHttpClient): Retrofit = Retrofit.Builder() + .baseUrl("https://${BuildConfig.BACKEND_URI}/api/") + .client(client) + .addConverterFactory(json.asConverterFactory("application/json; charset=utf-8".toMediaType())) + .build() + + @Provides + @Singleton + fun provideCourseGrabService(retrofit: Retrofit): CourseGrabService = + retrofit.create(CourseGrabService::class.java) +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt deleted file mode 100644 index 4fd10d9..0000000 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.cornellappdev.coursegrab.networking - -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONObject - -private val JSON = "application/json; charset=utf-8".toMediaTypeOrNull() - -/** Builds a JSON request body. A null value omits the field, per [JSONObject.put]. */ -private fun jsonBody(vararg fields: Pair): RequestBody = - JSONObject().apply { fields.forEach { (key, value) -> put(key, value) } } - .toString() - .toRequestBody(contentType = JSON) - -private fun bearer(token: String): Map = - mapOf("Authorization" to "Bearer $token") - -fun Endpoint.Companion.initializeSession(googleToken: String, deviceToken: String?): Endpoint = - Endpoint( - path = "/session/initialize/", - body = jsonBody( - "token" to googleToken, - "device_type" to "ANDROID", - "device_token" to deviceToken - ), - method = EndpointMethod.POST - ) - -fun Endpoint.Companion.updateSession(updateToken: String): Endpoint = - Endpoint( - path = "/session/update/", - headers = bearer(updateToken), - body = jsonBody(), - method = EndpointMethod.POST - ) - -fun Endpoint.Companion.getTracking(accessToken: String): Endpoint = - Endpoint( - path = "/users/tracking/", - headers = bearer(accessToken), - method = EndpointMethod.GET - ) - -fun Endpoint.Companion.searchCourses(accessToken: String, query: String): Endpoint = - Endpoint( - path = "/courses/search/", - headers = bearer(accessToken), - body = jsonBody("query" to query), - method = EndpointMethod.POST - ) - -fun Endpoint.Companion.addTracking(accessToken: String, courseId: Int): Endpoint = - Endpoint( - path = "/sections/track/", - headers = bearer(accessToken), - body = jsonBody("course_id" to courseId), - method = EndpointMethod.POST - ) - -fun Endpoint.Companion.removeTracking(accessToken: String, courseId: Int): Endpoint = - Endpoint( - path = "/sections/untrack/", - headers = bearer(accessToken), - body = jsonBody("course_id" to courseId), - method = EndpointMethod.POST - ) - -fun Endpoint.Companion.deviceToken(accessToken: String, deviceToken: String): Endpoint = - Endpoint( - path = "/users/device-token/", - headers = bearer(accessToken), - body = jsonBody("device_token" to deviceToken), - method = EndpointMethod.POST - ) - -fun Endpoint.Companion.setNotification(accessToken: String, notifSetting: String): Endpoint = - Endpoint( - path = "/users/notification/", - headers = bearer(accessToken), - body = jsonBody("notification" to notifSetting), - method = EndpointMethod.POST - ) - -fun Endpoint.Companion.getCourseByID(accessToken: String, courseId: Int): Endpoint = - Endpoint( - path = "/courses/$courseId/", - headers = bearer(accessToken), - method = EndpointMethod.GET - ) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt b/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt index fad1b07..52485ff 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt @@ -6,6 +6,7 @@ import android.app.PendingIntent import android.content.Intent import android.media.RingtoneManager import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat import com.cornellappdev.coursegrab.R import com.cornellappdev.coursegrab.models.CourseNotification @@ -13,12 +14,12 @@ import com.cornellappdev.coursegrab.networking.CourseGrabRepository import com.cornellappdev.coursegrab.ui.notification.NotificationModal import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json import javax.inject.Inject @AndroidEntryPoint @@ -27,6 +28,9 @@ class NotificationService : FirebaseMessagingService() { @Inject lateinit var repository: CourseGrabRepository + @Inject + lateinit var json: Json + /** * Called when message is received. * @@ -34,15 +38,16 @@ class NotificationService : FirebaseMessagingService() { */ override fun onMessageReceived(remoteMessage: RemoteMessage) { // Check if message contains a data payload. - remoteMessage.data.isNotEmpty().let { - val courseInfoRaw = Gson() - val courseInfo = courseInfoRaw.fromJson( - remoteMessage.data["message"].toString(), - object : TypeToken() {}.type - ) + val payload = remoteMessage.data["message"] + if (payload != null) { + val courseInfo = try { + json.decodeFromString(payload) + } catch (e: SerializationException) { + Log.w(TAG, "Unreadable notification payload", e) + null + } - if (courseInfo != null) - sendNotification(courseInfo) + if (courseInfo != null) sendNotification(courseInfo) } // Check if message contains a notification payload. @@ -117,4 +122,8 @@ class NotificationService : FirebaseMessagingService() { notificationManager.notify(course.section.catalog_num, notificationBuilder.build()) } + + private companion object { + const val TAG = "NotificationService" + } } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt index 948d0f2..621a1ca 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt @@ -223,9 +223,9 @@ class LoginViewModel @Inject constructor( userSession.update_token.isNullOrBlank() ) return - val expiresAt = userSession.session_expiration?.toLongOrNull() + val expiresAt = userSession.session_expiration if (expiresAt == null) { - Log.e(TAG, "Unusable session expiration: ${userSession.session_expiration}") + Log.e(TAG, "Session response had no expiration") emitError(SIGN_IN_FAILED) return } diff --git a/build.gradle b/build.gradle index 4d29998..8e1ab03 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,8 @@ buildscript { ext.kotlin_version = '2.2.0' ext.ksp_version = '2.2.0-2.0.2' ext.hilt_version = '2.56.2' + ext.serialization_json_version = '1.9.0' + ext.retrofit_version = '3.0.0' repositories { google() mavenCentral() @@ -16,6 +18,7 @@ buildscript { classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.8' classpath "com.google.devtools.ksp:symbol-processing-gradle-plugin:$ksp_version" classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version" + classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } From 559ea0ccb85d1d7518911fb103c8f97f74e4b014 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 17:19:36 -0400 Subject: [PATCH 2/3] Cleanup --- .../cornellappdev/coursegrab/models/Course.kt | 16 ++++++---------- .../coursegrab/models/SearchResult.kt | 5 +++-- .../coursegrab/models/UserSession.kt | 7 ++++--- .../networking/CourseGrabRepository.kt | 10 +--------- .../coursegrab/networking/CourseGrabService.kt | 13 +++++-------- .../coursegrab/networking/NetworkModule.kt | 1 - .../coursegrab/service/NotificationService.kt | 2 +- .../ui/details/CourseDetailsActivity.kt | 12 ++++++------ .../coursegrab/ui/login/LoginViewModel.kt | 10 +++++----- .../coursegrab/ui/main/MainActivity.kt | 16 ++++++++-------- .../ui/notification/NotificationModal.kt | 4 ++-- .../coursegrab/ui/search/SearchActivity.kt | 2 +- 12 files changed, 42 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt index 1c807d4..5509f82 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/Course.kt @@ -2,29 +2,25 @@ package com.cornellappdev.coursegrab.models import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -/** - * Also carries failure payloads, where only [errors] is present — hence the defaults on - * every other property. - */ @Serializable @Parcelize data class Course( - val catalog_num: Int = 0, - val course_num: Int = 0, + @SerialName("catalog_num") val catalogNum: Int = 0, + @SerialName("course_num") val courseNum: Int = 0, val section: String = "", val instructors: List = emptyList(), - val is_tracking: Boolean = false, + @SerialName("is_tracking") val isTracking: Boolean = false, val status: String = "", - val subject_code: String = "", + @SerialName("subject_code") val subjectCode: String = "", val title: String = "", - val num_tracking: Int = 0, + @SerialName("num_tracking") val numTracking: Int = 0, val mode: String = "", val errors: List? = null ) : Parcelable { - /** The backend reports section availability through [status] rather than a flag. */ val isOpen: Boolean get() = status == STATUS_OPEN private companion object { diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/SearchResult.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/SearchResult.kt index c91423f..c2f0e8c 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/SearchResult.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/SearchResult.kt @@ -2,13 +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 ) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/coursegrab/models/UserSession.kt b/app/src/main/java/com/cornellappdev/coursegrab/models/UserSession.kt index 5bfdda3..730cc62 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/models/UserSession.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/models/UserSession.kt @@ -1,10 +1,11 @@ package com.cornellappdev.coursegrab.models +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable class UserSession( - val session_token: String? = null, - val update_token: String? = null, - val session_expiration: Long? = null + @SerialName("session_token") val sessionToken: String? = null, + @SerialName("update_token") val updateToken: String? = null, + @SerialName("session_expiration") val sessionExpiration: Long? = null ) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt index beac98d..fe09341 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt @@ -24,7 +24,7 @@ class CourseGrabRepository @Inject constructor( service.initializeSession( InitializeSessionRequest( googleToken, - device_token = deviceToken + deviceToken = deviceToken ) ) } @@ -58,19 +58,12 @@ class CourseGrabRepository @Inject constructor( ) } - /** - * Unwraps the [ApiResponse] envelope. Retrofit dispatches off the caller's thread and - * throws on transport, HTTP, and decoding failures, so all this adds is turning the - * envelope's own `success` flag into a failure. - */ private suspend fun call(request: suspend () -> ApiResponse): Result = try { val envelope = request() if (envelope.success) { Result.success(envelope.data) } else { - // `errors` only exists on Course payloads; other endpoints report failure - // through the flag alone. Result.failure( ApiException( (envelope.data as? Course)?.errors?.firstOrNull() ?: "Request failed" @@ -78,7 +71,6 @@ class CourseGrabRepository @Inject constructor( ) } } catch (e: CancellationException) { - // Never swallow cancellation — the caller's scope is shutting down. throw e } catch (e: Exception) { Result.failure(e) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt index 5a0288c..910f698 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt @@ -6,6 +6,7 @@ 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 @@ -13,10 +14,6 @@ import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.Path -/** - * The Authorization header is per-endpoint rather than an interceptor: `initializeSession` - * sends none, and `updateSession` sends the update token instead of the session token. - */ interface CourseGrabService { @POST("session/initialize/") @@ -78,18 +75,18 @@ class EmptyRequest @Serializable data class InitializeSessionRequest( val token: String, - val device_type: String = "ANDROID", - val device_token: String? = null + @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(val course_id: Int) +data class CourseIdRequest(@SerialName("course_id") val courseId: Int) @Serializable -data class DeviceTokenRequest(val device_token: String) +data class DeviceTokenRequest(@SerialName("device_token") val deviceToken: String) @Serializable data class NotificationRequest(val notification: String) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/NetworkModule.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/NetworkModule.kt index 4b568ed..674fbb8 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/NetworkModule.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/NetworkModule.kt @@ -19,7 +19,6 @@ object NetworkModule { @Provides @Singleton fun provideJson(): Json = Json { - // The backend may add fields we don't model; that shouldn't fail a response. ignoreUnknownKeys = true encodeDefaults = true } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt b/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt index 52485ff..1fb0f8d 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/service/NotificationService.kt @@ -120,7 +120,7 @@ class NotificationService : FirebaseMessagingService() { notificationManager.createNotificationChannel(channel) } - notificationManager.notify(course.section.catalog_num, notificationBuilder.build()) + notificationManager.notify(course.section.catalogNum, notificationBuilder.build()) } private companion object { diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsActivity.kt index 1f2966c..00683b8 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/details/CourseDetailsActivity.kt @@ -36,7 +36,7 @@ class CourseDetailsActivity : AppCompatActivity() { val courseDetails: SearchResult = intent.getParcelableExtra("courseDetails") as SearchResult - binding.headerTextView.text = "${courseDetails.subject_code} ${courseDetails.course_num}" + binding.headerTextView.text = "${courseDetails.subjectCode} ${courseDetails.courseNum}" binding.courseTitle.text = courseDetails.title binding.courseDetails.text = @@ -91,17 +91,17 @@ class CourseDetailsActivity : AppCompatActivity() { holder.sectionStatus.setImageResource(if (availableCourses[position].isOpen) R.drawable.ic_status_open else R.drawable.ic_status_closed) //change tracking text - holder.trackText.text = availableCourses[position].num_tracking.toString() + " Tracking" + holder.trackText.text = availableCourses[position].numTracking.toString() + " Tracking" //Change the track button to remove button holder.removeButton.visibility = - if (availableCourses[position].is_tracking) View.VISIBLE else View.GONE + if (availableCourses[position].isTracking) View.VISIBLE else View.GONE holder.trackButton.visibility = - if (!availableCourses[position].is_tracking) View.VISIBLE else View.GONE + if (!availableCourses[position].isTracking) View.VISIBLE else View.GONE holder.removeButton.setOnClickListener { (context as CourseDetailsActivity).removeCourse( - availableCourses[position].catalog_num + availableCourses[position].catalogNum ) holder.removeButton.visibility = View.GONE holder.trackButton.visibility = View.VISIBLE @@ -115,7 +115,7 @@ class CourseDetailsActivity : AppCompatActivity() { holder.trackButton.setOnClickListener { (context as CourseDetailsActivity).addCourse( - availableCourses[position].catalog_num + availableCourses[position].catalogNum ) holder.trackButton.visibility = View.GONE holder.removeButton.visibility = View.VISIBLE diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt index 621a1ca..60f41e1 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/login/LoginViewModel.kt @@ -219,19 +219,19 @@ class LoginViewModel @Inject constructor( } private suspend fun verifySession(userSession: UserSession) { - if (userSession.session_token.isNullOrBlank() || - userSession.update_token.isNullOrBlank() + if (userSession.sessionToken.isNullOrBlank() || + userSession.updateToken.isNullOrBlank() ) return - val expiresAt = userSession.session_expiration + val expiresAt = userSession.sessionExpiration if (expiresAt == null) { Log.e(TAG, "Session response had no expiration") emitError(SIGN_IN_FAILED) return } - preferencesHelper.sessionToken = userSession.session_token - preferencesHelper.updateToken = userSession.update_token + preferencesHelper.sessionToken = userSession.sessionToken + preferencesHelper.updateToken = userSession.updateToken preferencesHelper.expiresAt = expiresAt FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainActivity.kt index e225640..3cf3d2e 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/main/MainActivity.kt @@ -210,14 +210,14 @@ class MainActivity : AppCompatActivity() { override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.courseTitle.text = - "${availableCourses[position].subject_code} ${availableCourses[position].course_num}: ${availableCourses[position].title}" + "${availableCourses[position].subjectCode} ${availableCourses[position].courseNum}: ${availableCourses[position].title}" holder.courseTime.text = availableCourses[position].section.uppercase(getDefault()) - holder.coursePin.text = availableCourses[position].catalog_num.toString() + holder.coursePin.text = availableCourses[position].catalogNum.toString() holder.courseStatus.setImageResource(if (availableCourses[position].isOpen) R.drawable.ic_status_open else R.drawable.ic_status_closed) holder.removeButton.setOnClickListener { (context as MainActivity).removeCourse( - availableCourses[position].catalog_num + availableCourses[position].catalogNum ) } @@ -228,7 +228,7 @@ class MainActivity : AppCompatActivity() { holder.backgroundButton.setOnClickListener { (context as MainActivity).editCourse( - availableCourses[position].catalog_num + availableCourses[position].catalogNum ) } } @@ -261,19 +261,19 @@ class MainActivity : AppCompatActivity() { override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.courseTitle.text = - "${awaitingCourses[position].subject_code} ${awaitingCourses[position].course_num}: ${awaitingCourses[position].title}" + "${awaitingCourses[position].subjectCode} ${awaitingCourses[position].courseNum}: ${awaitingCourses[position].title}" holder.courseTime.text = awaitingCourses[position].section.uppercase(getDefault()) - holder.coursePin.text = awaitingCourses[position].catalog_num.toString() + holder.coursePin.text = awaitingCourses[position].catalogNum.toString() holder.courseStatus.setImageResource(if (awaitingCourses[position].isOpen) R.drawable.ic_status_open else R.drawable.ic_status_closed) holder.removeButton.setOnClickListener { (context as MainActivity).removeCourse( - awaitingCourses[position].catalog_num + awaitingCourses[position].catalogNum ) } holder.backgroundButton.setOnClickListener { (context as MainActivity).editCourse( - awaitingCourses[position].catalog_num + awaitingCourses[position].catalogNum ) } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/notification/NotificationModal.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/notification/NotificationModal.kt index 6455ab4..72827b6 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/notification/NotificationModal.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/notification/NotificationModal.kt @@ -22,9 +22,9 @@ class NotificationModal : AppCompatActivity() { val course: Course = (intent.getParcelableExtra("courseDetails") as CourseNotification).section - binding.courseTitle.text = "${course.subject_code} ${course.course_num}: ${course.title}" + binding.courseTitle.text = "${course.subjectCode} ${course.courseNum}: ${course.title}" binding.courseSection.text = course.section - binding.coursePin.text = course.catalog_num.toString() + binding.coursePin.text = course.catalogNum.toString() binding.buttonStudentCenter.setOnClickListener { val browserIntent = diff --git a/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchActivity.kt index 4d8fd45..95c5b07 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/ui/search/SearchActivity.kt @@ -140,7 +140,7 @@ class SearchActivity : AppCompatActivity() { override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.courseTitle.text = - "${resultsCourses[position].subject_code} ${resultsCourses[position].course_num}: ${resultsCourses[position].title}" + "${resultsCourses[position].subjectCode} ${resultsCourses[position].courseNum}: ${resultsCourses[position].title}" holder.expandButton.setOnClickListener { val intent = Intent(context, CourseDetailsActivity::class.java).apply { From b82da4d8682fa3ddeec5b723ebd95143b36dc951 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 17:29:27 -0400 Subject: [PATCH 3/3] Remove EmptyRequest --- .../coursegrab/networking/CourseGrabService.kt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt index 910f698..e1a9cb7 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabService.kt @@ -23,8 +23,7 @@ interface CourseGrabService { @POST("session/update/") suspend fun updateSession( - @Header("Authorization") auth: String, - @Body body: EmptyRequest = EmptyRequest() + @Header("Authorization") auth: String ): ApiResponse @GET("users/tracking/") @@ -69,9 +68,6 @@ interface CourseGrabService { ): ApiResponse } -@Serializable -class EmptyRequest - @Serializable data class InitializeSessionRequest( val token: String,