From c536035d28175cecdc2c9779539a86de8c2cc4a0 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Fri, 28 Aug 2026 16:39:34 -0400 Subject: [PATCH 1/6] Implement repo --- .../coursegrab/CourseDetailsActivity.kt | 51 ++---- .../cornellappdev/coursegrab/LoginActivity.kt | 100 +++-------- .../cornellappdev/coursegrab/MainActivity.kt | 104 +++++------ .../coursegrab/NotificationService.kt | 24 +-- .../coursegrab/SearchActivity.kt | 45 +++-- .../coursegrab/SettingsActivity.kt | 48 +++-- .../networking/CourseGrabRepository.kt | 111 ++++++++++++ .../coursegrab/networking/Request.kt | 72 ++++---- .../coursegrab/networking/UserEndpoints.kt | 165 ++++++------------ app/src/main/res/values/strings.xml | 4 + 10 files changed, 324 insertions(+), 400 deletions(-) create mode 100644 app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt diff --git a/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsActivity.kt index 2627e0d..8869898 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/CourseDetailsActivity.kt @@ -2,6 +2,7 @@ package com.cornellappdev.coursegrab import android.content.Context import android.os.Bundle +import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -14,17 +15,10 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.cornellappdev.coursegrab.databinding.ActivityCourseDetailsBinding -import com.cornellappdev.coursegrab.models.ApiResponse import com.cornellappdev.coursegrab.models.Course import com.cornellappdev.coursegrab.models.SearchResult -import com.cornellappdev.coursegrab.networking.Endpoint -import com.cornellappdev.coursegrab.networking.Request -import com.cornellappdev.coursegrab.networking.addTracking -import com.cornellappdev.coursegrab.networking.removeTracking -import com.google.gson.reflect.TypeToken -import kotlinx.coroutines.Dispatchers +import com.cornellappdev.coursegrab.networking.CourseGrabRepository import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class CourseDetailsActivity : AppCompatActivity() { private lateinit var binding: ActivityCourseDetailsBinding @@ -33,8 +27,8 @@ class CourseDetailsActivity : AppCompatActivity() { private lateinit var sectionsViewAdapter: RecyclerView.Adapter<*> private lateinit var sectionsViewManager: RecyclerView.LayoutManager - private val preferencesHelper: PreferencesHelper by lazy { - PreferencesHelper(this) + private val repository: CourseGrabRepository by lazy { + CourseGrabRepository(PreferencesHelper(this)) } override fun onCreate(savedInstanceState: Bundle?) { @@ -63,45 +57,28 @@ class CourseDetailsActivity : AppCompatActivity() { } fun addCourse(courseId: Int, context: Context) { - val addTracking = Endpoint.addTracking(preferencesHelper.sessionToken.toString(), courseId) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val response = withContext(Dispatchers.IO) { - Request.makeRequest>( - addTracking.okHttpRequest(), - typeToken - ) - } - - if (!response!!.success) + repository.addTracking(courseId).onFailure { error -> + Log.e(TAG, "Failed to track course $courseId", error) Toast.makeText( context, - response.data.errors!![0], + error.message ?: "Couldn't track that course.", Toast.LENGTH_SHORT ).show() + } } } fun removeCourse(courseId: Int, context: Context) { - val removeTracking = - Endpoint.removeTracking(preferencesHelper.sessionToken.toString(), courseId) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val response = withContext(Dispatchers.IO) { - Request.makeRequest>( - removeTracking.okHttpRequest(), - typeToken - ) - } - - if (!response!!.success) + repository.removeTracking(courseId).onFailure { error -> + Log.e(TAG, "Failed to untrack course $courseId", error) Toast.makeText( context, - response.data.errors!![0], + error.message ?: "Couldn't remove that course.", Toast.LENGTH_SHORT ).show() + } } } @@ -176,4 +153,8 @@ class CourseDetailsActivity : AppCompatActivity() { // Return the size of your dataset (invoked by the layout manager) override fun getItemCount() = availableCourses.size } + + companion object { + private const val TAG = "CourseDetailsActivity" + } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt index cb4c6bb..a8e4081 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt @@ -17,26 +17,15 @@ import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException import androidx.lifecycle.lifecycleScope import com.cornellappdev.coursegrab.databinding.ActivityLoginBinding -import com.cornellappdev.coursegrab.models.ApiResponse -import com.cornellappdev.coursegrab.models.Course import com.cornellappdev.coursegrab.models.UserSession -import com.cornellappdev.coursegrab.networking.Endpoint -import com.cornellappdev.coursegrab.networking.Request -import com.cornellappdev.coursegrab.networking.deviceToken -import com.cornellappdev.coursegrab.networking.initializeSession -import com.cornellappdev.coursegrab.networking.setNotification -import com.cornellappdev.coursegrab.networking.updateSession +import com.cornellappdev.coursegrab.networking.CourseGrabRepository import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException import com.google.android.material.snackbar.Snackbar import com.google.firebase.messaging.FirebaseMessaging -import com.google.gson.reflect.TypeToken -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class LoginActivity : AppCompatActivity() { private lateinit var binding: ActivityLoginBinding @@ -52,6 +41,10 @@ class LoginActivity : AppCompatActivity() { PreferencesHelper(this) } + private val repository: CourseGrabRepository by lazy { + CourseGrabRepository(preferencesHelper) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityLoginBinding.inflate(layoutInflater) @@ -63,25 +56,11 @@ class LoginActivity : AppCompatActivity() { } else { val updateToken = preferencesHelper.updateToken if (!updateToken.isNullOrBlank()) { - val updateSession = Endpoint.updateSession(updateToken) - lifecycleScope.launch { - val userSession = try { - val typeToken = object : TypeToken>() {}.type - withContext(Dispatchers.IO) { - Request.makeRequest>( - updateSession.okHttpRequest(), - typeToken - ) - }?.data - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.d(TAG, "Could not resume previous session", e) - null - } - - if (userSession != null) verifySession(userSession) + repository.updateSession(updateToken) + .onSuccess { verifySession(it) } + // The user simply stays on the login screen and signs in again. + .onFailure { Log.d(TAG, "Could not resume previous session", it) } } } } @@ -191,30 +170,13 @@ class LoginActivity : AppCompatActivity() { return } - val initializeSession = Endpoint.initializeSession(googleCredential.idToken, null) - lifecycleScope.launch { - val userSession = try { - val typeToken = object : TypeToken>() {}.type - withContext(Dispatchers.IO) { - Request.makeRequest>( - initializeSession.okHttpRequest(), - typeToken - ) - }?.data - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.e(TAG, "Failed to initialize session", e) - null - } - - if (userSession == null) { - showLoginError("Sign-in failed. Please try again.") - return@launch - } - - verifySession(userSession) + repository.initializeSession(googleCredential.idToken, null) + .onSuccess { verifySession(it) } + .onFailure { error -> + Log.e(TAG, "Failed to initialize session", error) + showLoginError("Sign-in failed. Please try again.") + } } } @@ -238,22 +200,10 @@ class LoginActivity : AppCompatActivity() { } private fun sendRegistrationToServer(token: String?) { - val sendDeviceToken = Endpoint.deviceToken( - preferencesHelper.sessionToken.toString(), - token.toString() - ) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val response = withContext(Dispatchers.IO) { - Request.makeRequest>( - sendDeviceToken.okHttpRequest(), - typeToken - ) - } - - if (response!!.success) - Log.d("NotificationService", "sendRegistrationTokenToServer($token)") + repository.sendDeviceToken(token.toString()) + .onSuccess { Log.d(TAG, "sendRegistrationTokenToServer($token)") } + .onFailure { Log.w(TAG, "Failed to register device token", it) } } } @@ -279,19 +229,9 @@ class LoginActivity : AppCompatActivity() { } private fun setNotificationsStatus(enabled: Boolean) { - val setNotifs = Endpoint.setNotification( - accessToken = preferencesHelper.sessionToken.toString(), - notifSetting = if (enabled) "ANDROID" else "NONE" - ) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - withContext(Dispatchers.IO) { - Request.makeRequest>( - setNotifs.okHttpRequest(), - typeToken - ) - } + repository.setNotifications(enabled) + .onFailure { Log.w(TAG, "Failed to update notifications", it) } } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt index b559f86..168102f 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/MainActivity.kt @@ -8,6 +8,7 @@ import android.os.Build import android.os.Bundle import android.text.Editable import android.text.TextWatcher +import android.util.Log import android.view.KeyEvent import android.view.LayoutInflater import android.view.View @@ -25,20 +26,9 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.cornellappdev.coursegrab.databinding.ActivityMainBinding -import com.cornellappdev.coursegrab.models.ApiResponse import com.cornellappdev.coursegrab.models.Course -import com.cornellappdev.coursegrab.models.SearchResult -import com.cornellappdev.coursegrab.models.TrackingContainer -import com.cornellappdev.coursegrab.networking.Endpoint -import com.cornellappdev.coursegrab.networking.Request -import com.cornellappdev.coursegrab.networking.addTracking -import com.cornellappdev.coursegrab.networking.getCourseByID -import com.cornellappdev.coursegrab.networking.getTracking -import com.cornellappdev.coursegrab.networking.removeTracking -import com.google.gson.reflect.TypeToken -import kotlinx.coroutines.Dispatchers +import com.cornellappdev.coursegrab.networking.CourseGrabRepository import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.Locale.getDefault @@ -53,8 +43,8 @@ class MainActivity : AppCompatActivity() { private lateinit var awaitingViewAdapter: RecyclerView.Adapter<*> private lateinit var awaitingViewManager: RecyclerView.LayoutManager - private val preferencesHelper: PreferencesHelper by lazy { - PreferencesHelper(this) + private val repository: CourseGrabRepository by lazy { + CourseGrabRepository(PreferencesHelper(this)) } override fun onCreate(savedInstanceState: Bundle?) { @@ -144,27 +134,21 @@ class MainActivity : AppCompatActivity() { } private fun refreshAwaiting() { - val listOpen = mutableListOf() - val listAwaiting = mutableListOf() - - val getTracking = Endpoint.getTracking(preferencesHelper.sessionToken.toString()) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val courseList = withContext(Dispatchers.IO) { - Request.makeRequest>( - getTracking.okHttpRequest(), - typeToken - ) - }!!.data.sections - - for (course in courseList) { - if (course.status == "OPEN") - listOpen.add(course) - else - listAwaiting.add(course) + val courseList = repository.getTracking().getOrElse { error -> + Log.e(TAG, "Failed to load tracked courses", error) + binding.refreshCoursesLayout.isRefreshing = false + Toast.makeText( + this@MainActivity, + "Couldn't load your courses.", + Toast.LENGTH_SHORT + ).show() + return@launch } + val listOpen = courseList.filter { it.status == "OPEN" } + val listAwaiting = courseList.filter { it.status != "OPEN" } + // Available Courses Adapter availableViewManager = LinearLayoutManager(this@MainActivity) availableViewAdapter = AvailableAdapter(listOpen, this@MainActivity) @@ -198,64 +182,50 @@ class MainActivity : AppCompatActivity() { } private fun addCourse(courseId: Int, context: Context) { - val addTracking = Endpoint.addTracking(preferencesHelper.sessionToken.toString(), courseId) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val response = withContext(Dispatchers.IO) { - Request.makeRequest>( - addTracking.okHttpRequest(), - typeToken - ) - } + val result = repository.addTracking(courseId) refreshAwaiting() - if (!response!!.success) + result.onFailure { error -> + Log.e(TAG, "Failed to track course $courseId", error) Toast.makeText( context, - response.data.errors!![0], + error.message ?: "Couldn't track that course.", Toast.LENGTH_SHORT ).show() + } } } private fun removeCourse(courseId: Int, context: Context) { - val removeTracking = - Endpoint.removeTracking(preferencesHelper.sessionToken.toString(), courseId) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val response = withContext(Dispatchers.IO) { - Request.makeRequest>( - removeTracking.okHttpRequest(), - typeToken - ) - } + val result = repository.removeTracking(courseId) refreshAwaiting() - if (!response!!.success) + result.onFailure { error -> + Log.e(TAG, "Failed to untrack course $courseId", error) Toast.makeText( context, - response.data.errors!![0], + error.message ?: "Couldn't remove that course.", Toast.LENGTH_SHORT ).show() + } } } private fun editCourse(courseId: Int, context: Context) { - val editCourse = - Endpoint.getCourseByID(preferencesHelper.sessionToken.toString(), courseId) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val course = withContext(Dispatchers.IO) { - Request.makeRequest>( - editCourse.okHttpRequest(), - typeToken - ) - }!!.data + val course = repository.getCourseById(courseId).getOrElse { error -> + Log.e(TAG, "Failed to load course $courseId", error) + Toast.makeText( + context, + "Couldn't open that course.", + Toast.LENGTH_SHORT + ).show() + return@launch + } val intent = Intent(context, CourseDetailsActivity::class.java).apply { putExtra("courseDetails", course) @@ -368,4 +338,8 @@ class MainActivity : AppCompatActivity() { // Return the size of your dataset (invoked by the layout manager) override fun getItemCount() = awaitingCourses.size } + + companion object { + private const val TAG = "MainActivity" + } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt b/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt index 57a5a7f..5c51f43 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt @@ -7,12 +7,8 @@ import android.content.Intent import android.media.RingtoneManager import android.os.Build import androidx.core.app.NotificationCompat -import com.cornellappdev.coursegrab.models.ApiResponse -import com.cornellappdev.coursegrab.models.Course import com.cornellappdev.coursegrab.models.CourseNotification -import com.cornellappdev.coursegrab.networking.Endpoint -import com.cornellappdev.coursegrab.networking.Request -import com.cornellappdev.coursegrab.networking.deviceToken +import com.cornellappdev.coursegrab.networking.CourseGrabRepository import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.google.gson.Gson @@ -20,12 +16,11 @@ import com.google.gson.reflect.TypeToken import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class NotificationService : FirebaseMessagingService() { - private val preferencesHelper: PreferencesHelper by lazy { - PreferencesHelper(this) + private val repository: CourseGrabRepository by lazy { + CourseGrabRepository(PreferencesHelper(this)) } /** @@ -71,19 +66,8 @@ class NotificationService : FirebaseMessagingService() { * @param token The new token. */ private fun sendRegistrationToServer(token: String?) { - val sendDeviceToken = Endpoint.deviceToken( - preferencesHelper.sessionToken.toString(), - token.toString() - ) - CoroutineScope(Dispatchers.Main).launch { - val typeToken = object : TypeToken>() {}.type - withContext(Dispatchers.IO) { - Request.makeRequest>( - sendDeviceToken.okHttpRequest(), - typeToken - ) - } + repository.sendDeviceToken(token.toString()) } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt index 228bef2..36a873b 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt @@ -3,6 +3,7 @@ package com.cornellappdev.coursegrab import android.content.Context import android.content.Intent import android.os.Bundle +import android.util.Log import android.view.KeyEvent import android.view.LayoutInflater import android.view.View @@ -17,16 +18,9 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.cornellappdev.coursegrab.databinding.ActivitySearchBinding -import com.cornellappdev.coursegrab.models.ApiResponse -import com.cornellappdev.coursegrab.models.SearchContainer import com.cornellappdev.coursegrab.models.SearchResult -import com.cornellappdev.coursegrab.networking.Endpoint -import com.cornellappdev.coursegrab.networking.Request -import com.cornellappdev.coursegrab.networking.searchCourses -import com.google.gson.reflect.TypeToken -import kotlinx.coroutines.Dispatchers +import com.cornellappdev.coursegrab.networking.CourseGrabRepository import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class SearchActivity : AppCompatActivity() { private lateinit var binding: ActivitySearchBinding @@ -35,8 +29,8 @@ class SearchActivity : AppCompatActivity() { private lateinit var searchViewAdapter: RecyclerView.Adapter<*> private lateinit var searchViewManager: RecyclerView.LayoutManager - private val preferencesHelper: PreferencesHelper by lazy { - PreferencesHelper(this) + private val repository: CourseGrabRepository by lazy { + CourseGrabRepository(PreferencesHelper(this)) } override fun onCreate(savedInstanceState: Bundle?) { @@ -76,20 +70,19 @@ class SearchActivity : AppCompatActivity() { } private fun searchCourses(query: String) { - val getTracking = Endpoint.searchCourses(preferencesHelper.sessionToken.toString(), query) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val courseList = withContext(Dispatchers.IO) { - Request.makeRequest>( - getTracking.okHttpRequest(), - typeToken - ) - }!!.data.courses + val result = repository.searchCourses(query) + // A slower earlier request may land after the user has typed on; ignore it. if (binding.editTextSearch.text.toString() != query) return@launch + val courseList = result.getOrElse { error -> + Log.e(TAG, "Search failed for query \"$query\"", error) + showSearchError() + return@launch + } + // Results Courses Adapter searchViewManager = LinearLayoutManager(this@SearchActivity) searchViewAdapter = ResultsAdapter(courseList, this@SearchActivity) @@ -115,6 +108,16 @@ class SearchActivity : AppCompatActivity() { } } + private fun showSearchError() { + binding.layoutResults.visibility = View.GONE + binding.noResultsView.visibility = View.VISIBLE + binding.noResultsIcon.setImageDrawable( + ContextCompat.getDrawable(this, R.drawable.ic_status_warning) + ) + binding.noResultsTitle.text = getString(R.string.search_failed) + binding.noResultsSubtitle.text = getString(R.string.search_failed_subtext) + } + class ResultsAdapter( private val resultsCourses: List, private val context: Context @@ -150,4 +153,8 @@ class SearchActivity : AppCompatActivity() { // Return the size of your dataset (invoked by the layout manager) override fun getItemCount() = resultsCourses.size } + + companion object { + private const val TAG = "SearchActivity" + } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt index 31702fd..5d0b149 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/SettingsActivity.kt @@ -15,16 +15,9 @@ import androidx.credentials.CredentialManager import androidx.credentials.exceptions.ClearCredentialException import androidx.lifecycle.lifecycleScope import com.cornellappdev.coursegrab.databinding.ActivitySettingsBinding -import com.cornellappdev.coursegrab.models.ApiResponse -import com.cornellappdev.coursegrab.models.Course -import com.cornellappdev.coursegrab.networking.Endpoint -import com.cornellappdev.coursegrab.networking.Request -import com.cornellappdev.coursegrab.networking.setNotification +import com.cornellappdev.coursegrab.networking.CourseGrabRepository import com.google.firebase.messaging.FirebaseMessaging -import com.google.gson.reflect.TypeToken -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class SettingsActivity : AppCompatActivity() { @@ -38,6 +31,10 @@ class SettingsActivity : AppCompatActivity() { PreferencesHelper(this) } + private val repository: CourseGrabRepository by lazy { + CourseGrabRepository(preferencesHelper) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivitySettingsBinding.inflate(layoutInflater) @@ -107,26 +104,23 @@ class SettingsActivity : AppCompatActivity() { } private fun setNotificationsStatus(enabled: Boolean) { - val setNotifs = Endpoint.setNotification( - preferencesHelper.sessionToken.toString(), - if (enabled) "ANDROID" else "NONE" - ) - lifecycleScope.launch { - val typeToken = object : TypeToken>() {}.type - val response = withContext(Dispatchers.IO) { - Request.makeRequest>( - setNotifs.okHttpRequest(), - typeToken - ) - } - - if (response!!.success) - Toast.makeText( - this@SettingsActivity, - "Notifications ${if (enabled) "enabled." else "disabled."}", - Toast.LENGTH_SHORT - ).show() + repository.setNotifications(enabled) + .onSuccess { + Toast.makeText( + this@SettingsActivity, + "Notifications ${if (enabled) "enabled." else "disabled."}", + Toast.LENGTH_SHORT + ).show() + } + .onFailure { error -> + Log.e(TAG, "Failed to update notification setting", error) + Toast.makeText( + this@SettingsActivity, + "Couldn't update notification settings.", + Toast.LENGTH_SHORT + ).show() + } } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt new file mode 100644 index 0000000..bd89b36 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt @@ -0,0 +1,111 @@ +package com.cornellappdev.coursegrab.networking + +import com.cornellappdev.coursegrab.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 java.lang.reflect.Type + +/** Raised when the backend answers, but the answer isn't a usable payload. */ +class ApiException(message: String) : Exception(message) + +/** + * Single entry point for the CourseGrab backend. + * + * Callers get a [Result] and never have to touch [Endpoint], Gson type tokens, or the IO + * dispatcher. The session token is read here rather than at each call site, so screens no + * longer need a [PreferencesHelper] just to make a request. + */ +class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { + + private val gson = Gson() + + private val token: String + get() = preferencesHelper.sessionToken.orEmpty() + + suspend fun initializeSession(googleToken: String, deviceToken: String?): Result = + call(Endpoint.initializeSession(googleToken, deviceToken), userSessionType) + + suspend fun updateSession(updateToken: String): Result = + call(Endpoint.updateSession(updateToken), userSessionType) + + suspend fun getTracking(): Result> = + call(Endpoint.getTracking(token), trackingType).map { it.sections } + + suspend fun searchCourses(query: String): Result> = + call(Endpoint.searchCourses(token, query), searchType).map { it.courses } + + suspend fun addTracking(courseId: Int): Result = + call(Endpoint.addTracking(token, courseId), courseType) + + suspend fun removeTracking(courseId: Int): Result = + call(Endpoint.removeTracking(token, courseId), courseType) + + suspend fun getCourseById(courseId: Int): Result = + call(Endpoint.getCourseByID(token, courseId), searchResultType) + + suspend fun sendDeviceToken(deviceToken: String): Result = + call(Endpoint.deviceToken(token, deviceToken), courseType) + + suspend fun setNotifications(enabled: Boolean): Result = + call(Endpoint.setNotification(token, if (enabled) "ANDROID" else "NONE"), courseType) + + /** + * Runs [endpoint] on the IO dispatcher and unwraps the [ApiResponse] envelope. + * + * The body is parsed even for non-2xx responses, since the backend reports failures + * inside the envelope rather than through the status code. + */ + private suspend fun call(endpoint: Endpoint, type: Type): Result = + withContext(Dispatchers.IO) { + try { + val httpResponse = Request.httpClient.newCall(endpoint.okHttpRequest()).await() + 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 "Server error (${httpResponse.code})" + ) + ) + + !envelope.success -> Result.failure( + // `errors` only exists on Course payloads; other endpoints report + // failure through the flag alone. + ApiException((data as? Course)?.errors?.firstOrNull() ?: "Request failed") + ) + + else -> Result.success(data) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Result.failure(e) + } + } + + private companion object { + 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/Request.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt index dc8f7e0..9e6e18c 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt @@ -1,61 +1,49 @@ package com.cornellappdev.coursegrab.networking -import com.google.gson.Gson import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call -import okhttp3.Callback import okhttp3.OkHttpClient import okhttp3.Response import okio.IOException -import java.lang.reflect.Type import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException object Request { val httpClient = OkHttpClient() +} + +/** + * Suspend extension that allows suspend [Call] inside coroutine. + * + * @return Result of request or throw exception + */ +suspend fun Call.await(recordStackTrace: Boolean = true): Response { + val recordStackTrace = + if (recordStackTrace) IOException("Exception occurred while awaiting Call.") else null + return suspendCancellableCoroutine { continuation -> + enqueue(object : okhttp3.Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } - suspend inline fun makeRequest(request: okhttp3.Request, typeToken: Type): T? { - val response = httpClient.newCall(request).await() - val responseBody = response.body - val responseBodyString = responseBody.string() - - val responseBodyJSON = Gson() - return responseBodyJSON.fromJson(responseBodyString, typeToken) - } - - /** - * Suspend extension that allows suspend [Call] inside coroutine. - * - * @return Result of request or throw exception - */ - suspend fun Call.await(recordStackTrace: Boolean = true): Response { - val recordStackTrace = - if (recordStackTrace) IOException("Exception occurred while awaiting Call.") else null - return suspendCancellableCoroutine { continuation -> - enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - continuation.resume(response) - } - - override fun onFailure(call: Call, e: IOException) { - // Don't bother with resuming the continuation if it is already canceled. - if (recordStackTrace != null) { - recordStackTrace.initCause(e) - continuation.resumeWithException(recordStackTrace) - } else { - continuation.resumeWithException(e) - } + override fun onFailure(call: Call, e: IOException) { + // Don't bother with resuming the continuation if it is already canceled. + if (recordStackTrace != null) { + recordStackTrace.initCause(e) + continuation.resumeWithException(recordStackTrace) + } else { + continuation.resumeWithException(e) } - }) + } + }) - continuation.invokeOnCancellation { - try { - cancel() - } catch (_: Throwable) { - //Ignore cancel exception - } + continuation.invokeOnCancellation { + try { + cancel() + } catch (_: Throwable) { + //Ignore cancel exception } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt index a96cc73..fcfbb91 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt @@ -1,149 +1,90 @@ package com.cornellappdev.coursegrab.networking import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody -import org.json.JSONException import org.json.JSONObject -fun Endpoint.Companion.initializeSession(googleToken: String, deviceToken: String?): Endpoint { - val codeJSON = JSONObject() - try { - codeJSON.put("token", googleToken) - codeJSON.put("device_type", "ANDROID") - codeJSON.put("device_token", deviceToken) - } catch (e: JSONException) { - e.printStackTrace() - } - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint(path = "/session/initialize/", body = requestBody, method = EndpointMethod.POST) -} +private val JSON = "application/json; charset=utf-8".toMediaTypeOrNull() -fun Endpoint.Companion.updateSession(updateToken: String): Endpoint { - val codeJSON = JSONObject() - val authHeaders = mapOf(Pair("Authorization", "Bearer $updateToken")) - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint( +/** 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(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 = authHeaders, - body = requestBody, + headers = bearer(updateToken), + body = jsonBody(), method = EndpointMethod.POST ) -} -fun Endpoint.Companion.getTracking(accessToken: String): Endpoint { - val codeJSON = JSONObject() - val authHeaders = mapOf(Pair("Authorization", "Bearer $accessToken")) - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint( +fun Endpoint.Companion.getTracking(accessToken: String): Endpoint = + Endpoint( path = "/users/tracking/", - headers = authHeaders, - body = requestBody, + headers = bearer(accessToken), method = EndpointMethod.GET ) -} -fun Endpoint.Companion.searchCourses(accessToken: String, query: String): Endpoint { - val codeJSON = JSONObject() - try { - codeJSON.put("query", query) - } catch (e: JSONException) { - e.printStackTrace() - } - val authHeaders = mapOf(Pair("Authorization", "Bearer $accessToken")) - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint( +fun Endpoint.Companion.searchCourses(accessToken: String, query: String): Endpoint = + Endpoint( path = "/courses/search/", - headers = authHeaders, - body = requestBody, + headers = bearer(accessToken), + body = jsonBody("query" to query), method = EndpointMethod.POST ) -} -fun Endpoint.Companion.addTracking(accessToken: String, courseId: Int): Endpoint { - val codeJSON = JSONObject() - try { - codeJSON.put("course_id", courseId) - } catch (e: JSONException) { - e.printStackTrace() - } - val authHeaders = mapOf(Pair("Authorization", "Bearer $accessToken")) - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint( +fun Endpoint.Companion.addTracking(accessToken: String, courseId: Int): Endpoint = + Endpoint( path = "/sections/track/", - headers = authHeaders, - body = requestBody, + headers = bearer(accessToken), + body = jsonBody("course_id" to courseId), method = EndpointMethod.POST ) -} -fun Endpoint.Companion.removeTracking(accessToken: String, courseId: Int): Endpoint { - val codeJSON = JSONObject() - try { - codeJSON.put("course_id", courseId) - } catch (e: JSONException) { - e.printStackTrace() - } - val authHeaders = mapOf(Pair("Authorization", "Bearer $accessToken")) - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint( +fun Endpoint.Companion.removeTracking(accessToken: String, courseId: Int): Endpoint = + Endpoint( path = "/sections/untrack/", - headers = authHeaders, - body = requestBody, + headers = bearer(accessToken), + body = jsonBody("course_id" to courseId), method = EndpointMethod.POST ) -} -fun Endpoint.Companion.deviceToken(accessToken: String, deviceToken: String): Endpoint { - val codeJSON = JSONObject() - try { - codeJSON.put("device_token", deviceToken) - } catch (e: JSONException) { - e.printStackTrace() - } - val authHeaders = mapOf(Pair("Authorization", "Bearer $accessToken")) - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint( +fun Endpoint.Companion.deviceToken(accessToken: String, deviceToken: String): Endpoint = + Endpoint( path = "/users/device-token/", - headers = authHeaders, - body = requestBody, + headers = bearer(accessToken), + body = jsonBody("device_token" to deviceToken), method = EndpointMethod.POST ) -} -fun Endpoint.Companion.setNotification(accessToken: String, notifSetting: String): Endpoint { - val codeJSON = JSONObject() - try { - codeJSON.put("notification", notifSetting) - } catch (e: JSONException) { - e.printStackTrace() - } - val authHeaders = mapOf(Pair("Authorization", "Bearer $accessToken")) - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint( +fun Endpoint.Companion.setNotification(accessToken: String, notifSetting: String): Endpoint = + Endpoint( path = "/users/notification/", - headers = authHeaders, - body = requestBody, + headers = bearer(accessToken), + body = jsonBody("notification" to notifSetting), method = EndpointMethod.POST ) -} -fun Endpoint.Companion.getCourseByID(accessToken: String, courseId: Int): Endpoint { - val codeJSON = JSONObject() - val authHeaders = mapOf(Pair("Authorization", "Bearer $accessToken")) - val requestBody = - codeJSON.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) - return Endpoint( - path = "/courses/1240/", - headers = authHeaders, - body = requestBody, +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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6661ec9..efb59d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -43,6 +43,10 @@ Try a different query + Couldn\'t search right now + + Check your connection and try again + Available Now Course ID From 635691e635631dccbea26e4b0d2396b74ea6f105 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Fri, 28 Aug 2026 18:05:10 -0400 Subject: [PATCH 2/6] Clean up --- .../cornellappdev/coursegrab/LoginActivity.kt | 1 - .../coursegrab/SearchActivity.kt | 59 ++++++++++--------- .../networking/CourseGrabRepository.kt | 18 +----- .../coursegrab/networking/Endpoint.kt | 6 +- .../coursegrab/networking/Request.kt | 3 - .../coursegrab/networking/UserEndpoints.kt | 2 +- 6 files changed, 35 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt index a8e4081..27fa949 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt @@ -59,7 +59,6 @@ class LoginActivity : AppCompatActivity() { lifecycleScope.launch { repository.updateSession(updateToken) .onSuccess { verifySession(it) } - // The user simply stays on the login screen and signs in again. .onFailure { Log.d(TAG, "Could not resume previous session", it) } } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt index 36a873b..4b121b4 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt @@ -11,6 +11,8 @@ import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.ImageButton import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.widget.doOnTextChanged @@ -53,16 +55,11 @@ class SearchActivity : AppCompatActivity() { if ((text ?: "").length > 2) { searchCourses(text.toString()) } else { - binding.layoutResults.visibility = View.GONE - binding.noResultsView.visibility = View.VISIBLE - binding.noResultsIcon.setImageDrawable( - ContextCompat.getDrawable( - this, - R.drawable.ic_status_warning - ) + showEmptyState( + R.drawable.ic_status_warning, + R.string.requires_longer_search, + R.string.requires_longer_search_subtext ) - binding.noResultsTitle.text = getString(R.string.requires_longer_search) - binding.noResultsSubtitle.text = getString(R.string.requires_longer_search_subtext) } } @@ -79,7 +76,11 @@ class SearchActivity : AppCompatActivity() { val courseList = result.getOrElse { error -> Log.e(TAG, "Search failed for query \"$query\"", error) - showSearchError() + showEmptyState( + R.drawable.ic_status_warning, + R.string.search_failed, + R.string.search_failed_subtext + ) return@launch } @@ -93,29 +94,33 @@ class SearchActivity : AppCompatActivity() { } binding.resultTitle.text = "${binding.resultsList.adapter?.itemCount} Results" - binding.layoutResults.visibility = - if (courseList.isNotEmpty()) View.VISIBLE else View.GONE - binding.noResultsView.visibility = if (courseList.isEmpty()) View.VISIBLE else View.GONE - binding.noResultsIcon.setImageDrawable( - ContextCompat.getDrawable( - this@SearchActivity, - R.drawable.ic_status_closed + if (courseList.isEmpty()) { + showEmptyState( + R.drawable.ic_status_closed, + R.string.no_courses_alert, + R.string.no_results_alert_subtext_try_another ) - ) - binding.noResultsTitle.text = getString(R.string.no_courses_alert) - binding.noResultsSubtitle.text = - getString(R.string.no_results_alert_subtext_try_another) + } else { + showResults() + } } } - private fun showSearchError() { + private fun showEmptyState( + @DrawableRes icon: Int, + @StringRes title: Int, + @StringRes subtitle: Int + ) { binding.layoutResults.visibility = View.GONE binding.noResultsView.visibility = View.VISIBLE - binding.noResultsIcon.setImageDrawable( - ContextCompat.getDrawable(this, R.drawable.ic_status_warning) - ) - binding.noResultsTitle.text = getString(R.string.search_failed) - binding.noResultsSubtitle.text = getString(R.string.search_failed_subtext) + binding.noResultsIcon.setImageDrawable(ContextCompat.getDrawable(this, icon)) + binding.noResultsTitle.text = getString(title) + binding.noResultsSubtitle.text = getString(subtitle) + } + + private fun showResults() { + binding.layoutResults.visibility = View.VISIBLE + binding.noResultsView.visibility = View.GONE } class ResultsAdapter( 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 bd89b36..0d0ef0a 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt @@ -15,16 +15,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.lang.reflect.Type -/** Raised when the backend answers, but the answer isn't a usable payload. */ class ApiException(message: String) : Exception(message) -/** - * Single entry point for the CourseGrab backend. - * - * Callers get a [Result] and never have to touch [Endpoint], Gson type tokens, or the IO - * dispatcher. The session token is read here rather than at each call site, so screens no - * longer need a [PreferencesHelper] just to make a request. - */ class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { private val gson = Gson() @@ -59,12 +51,6 @@ class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { suspend fun setNotifications(enabled: Boolean): Result = call(Endpoint.setNotification(token, if (enabled) "ANDROID" else "NONE"), courseType) - /** - * Runs [endpoint] on the IO dispatcher and unwraps the [ApiResponse] envelope. - * - * The body is parsed even for non-2xx responses, since the backend reports failures - * inside the envelope rather than through the status code. - */ private suspend fun call(endpoint: Endpoint, type: Type): Result = withContext(Dispatchers.IO) { try { @@ -72,7 +58,7 @@ class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { val body = httpResponse.use { it.body.string() } val envelope: ApiResponse? = try { - gson.fromJson>(body, type) + gson.fromJson(body, type) } catch (_: JsonParseException) { null } @@ -87,8 +73,6 @@ class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { ) !envelope.success -> Result.failure( - // `errors` only exists on Course payloads; other endpoints report - // failure through the flag alone. ApiException((data as? Course)?.errors?.firstOrNull() ?: "Request failed") ) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/Endpoint.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/Endpoint.kt index f3763d4..24a1445 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/Endpoint.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/Endpoint.kt @@ -6,7 +6,7 @@ import okhttp3.Request import okhttp3.RequestBody enum class EndpointMethod { - GET, POST, DELETE, PUT + GET, POST } class Endpoint( @@ -39,10 +39,6 @@ class Endpoint( .get() .build() } - else -> { - throw IllegalArgumentException("NOT IMPLEMENTED") - } } } - } \ No newline at end of file diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt index 9e6e18c..4491308 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt @@ -8,7 +8,6 @@ import okio.IOException import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException - object Request { val httpClient = OkHttpClient() } @@ -28,7 +27,6 @@ suspend fun Call.await(recordStackTrace: Boolean = true): Response { } override fun onFailure(call: Call, e: IOException) { - // Don't bother with resuming the continuation if it is already canceled. if (recordStackTrace != null) { recordStackTrace.initCause(e) continuation.resumeWithException(recordStackTrace) @@ -42,7 +40,6 @@ suspend fun Call.await(recordStackTrace: Boolean = true): Response { try { cancel() } catch (_: Throwable) { - //Ignore cancel exception } } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt index fcfbb91..4fd10d9 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/UserEndpoints.kt @@ -11,7 +11,7 @@ private val JSON = "application/json; charset=utf-8".toMediaTypeOrNull() private fun jsonBody(vararg fields: Pair): RequestBody = JSONObject().apply { fields.forEach { (key, value) -> put(key, value) } } .toString() - .toRequestBody(JSON) + .toRequestBody(contentType = JSON) private fun bearer(token: String): Map = mapOf("Authorization" to "Bearer $token") From cca7b75f81b721f6dbdb01bac81b506f3917e118 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 09:58:05 -0400 Subject: [PATCH 3/6] Fix search request processing --- .../coursegrab/SearchActivity.kt | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt index 4b121b4..2c73cfd 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt @@ -22,6 +22,7 @@ import androidx.recyclerview.widget.RecyclerView import com.cornellappdev.coursegrab.databinding.ActivitySearchBinding import com.cornellappdev.coursegrab.models.SearchResult import com.cornellappdev.coursegrab.networking.CourseGrabRepository +import kotlinx.coroutines.Job import kotlinx.coroutines.launch class SearchActivity : AppCompatActivity() { @@ -35,6 +36,9 @@ class SearchActivity : AppCompatActivity() { CourseGrabRepository(PreferencesHelper(this)) } + /** The only search allowed to update the UI; superseded ones are canceled. */ + private var searchJob: Job? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivitySearchBinding.inflate(layoutInflater) @@ -55,10 +59,12 @@ class SearchActivity : AppCompatActivity() { if ((text ?: "").length > 2) { searchCourses(text.toString()) } else { + // The query is no longer searchable, so nothing in flight is current. + searchJob?.cancel() showEmptyState( - R.drawable.ic_status_warning, - R.string.requires_longer_search, - R.string.requires_longer_search_subtext + icon = R.drawable.ic_status_warning, + title = R.string.requires_longer_search, + subtitle = R.string.requires_longer_search_subtext ) } } @@ -67,19 +73,14 @@ class SearchActivity : AppCompatActivity() { } private fun searchCourses(query: String) { - lifecycleScope.launch { - val result = repository.searchCourses(query) - - // A slower earlier request may land after the user has typed on; ignore it. - if (binding.editTextSearch.text.toString() != query) - return@launch - - val courseList = result.getOrElse { error -> + searchJob?.cancel() + searchJob = lifecycleScope.launch { + val courseList = repository.searchCourses(query).getOrElse { error -> Log.e(TAG, "Search failed for query \"$query\"", error) showEmptyState( - R.drawable.ic_status_warning, - R.string.search_failed, - R.string.search_failed_subtext + icon = R.drawable.ic_status_warning, + title = R.string.search_failed, + subtitle = R.string.search_failed_subtext ) return@launch } @@ -96,9 +97,9 @@ class SearchActivity : AppCompatActivity() { if (courseList.isEmpty()) { showEmptyState( - R.drawable.ic_status_closed, - R.string.no_courses_alert, - R.string.no_results_alert_subtext_try_another + icon = R.drawable.ic_status_closed, + title = R.string.no_courses_alert, + subtitle = R.string.no_results_alert_subtext_try_another ) } else { showResults() From 80652bbfb87773389e685beea25c4e7b9b97c134 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 13:31:22 -0400 Subject: [PATCH 4/6] Use executeAsync --- app/build.gradle | 1 + .../networking/CourseGrabRepository.kt | 9 ++-- .../coursegrab/networking/Request.kt | 46 ------------------- 3 files changed, 6 insertions(+), 50 deletions(-) delete mode 100644 app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt diff --git a/app/build.gradle b/app/build.gradle index 2b37ae8..ae9f5e4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -54,6 +54,7 @@ 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 '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' 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 0d0ef0a..edab5ae 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt @@ -13,14 +13,13 @@ 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 class ApiException(message: String) : Exception(message) class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { - - private val gson = Gson() - private val token: String get() = preferencesHelper.sessionToken.orEmpty() @@ -54,7 +53,7 @@ class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { private suspend fun call(endpoint: Endpoint, type: Type): Result = withContext(Dispatchers.IO) { try { - val httpResponse = Request.httpClient.newCall(endpoint.okHttpRequest()).await() + val httpResponse = httpClient.newCall(endpoint.okHttpRequest()).executeAsync() val body = httpResponse.use { it.body.string() } val envelope: ApiResponse? = try { @@ -86,6 +85,8 @@ class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { } 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 diff --git a/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt b/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt deleted file mode 100644 index 4491308..0000000 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.cornellappdev.coursegrab.networking - -import kotlinx.coroutines.suspendCancellableCoroutine -import okhttp3.Call -import okhttp3.OkHttpClient -import okhttp3.Response -import okio.IOException -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -object Request { - val httpClient = OkHttpClient() -} - -/** - * Suspend extension that allows suspend [Call] inside coroutine. - * - * @return Result of request or throw exception - */ -suspend fun Call.await(recordStackTrace: Boolean = true): Response { - val recordStackTrace = - if (recordStackTrace) IOException("Exception occurred while awaiting Call.") else null - return suspendCancellableCoroutine { continuation -> - enqueue(object : okhttp3.Callback { - override fun onResponse(call: Call, response: Response) { - continuation.resume(response) - } - - override fun onFailure(call: Call, e: IOException) { - if (recordStackTrace != null) { - recordStackTrace.initCause(e) - continuation.resumeWithException(recordStackTrace) - } else { - continuation.resumeWithException(e) - } - } - }) - - continuation.invokeOnCancellation { - try { - cancel() - } catch (_: Throwable) { - } - } - } -} From 18beef1c82396f5b3f95ad37210bef405c01d7dc Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 13:40:16 -0400 Subject: [PATCH 5/6] Make token nonnullable --- .../main/java/com/cornellappdev/coursegrab/LoginActivity.kt | 4 ++-- .../java/com/cornellappdev/coursegrab/NotificationService.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt index 27fa949..41edf71 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/LoginActivity.kt @@ -198,9 +198,9 @@ class LoginActivity : AppCompatActivity() { Snackbar.make(binding.loginRootView, message, Snackbar.LENGTH_LONG).show() } - private fun sendRegistrationToServer(token: String?) { + private fun sendRegistrationToServer(token: String) { lifecycleScope.launch { - repository.sendDeviceToken(token.toString()) + repository.sendDeviceToken(token) .onSuccess { Log.d(TAG, "sendRegistrationTokenToServer($token)") } .onFailure { Log.w(TAG, "Failed to register device token", it) } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt b/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt index 5c51f43..e1f46e7 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/NotificationService.kt @@ -65,9 +65,9 @@ class NotificationService : FirebaseMessagingService() { * * @param token The new token. */ - private fun sendRegistrationToServer(token: String?) { + private fun sendRegistrationToServer(token: String) { CoroutineScope(Dispatchers.Main).launch { - repository.sendDeviceToken(token.toString()) + repository.sendDeviceToken(token) } } From 4daa9281d833e62d4160054a7651f6d1ffc9e9b3 Mon Sep 17 00:00:00 2001 From: Caleb Shim Date: Sat, 29 Aug 2026 13:43:48 -0400 Subject: [PATCH 6/6] Fix error message --- .../cornellappdev/coursegrab/networking/CourseGrabRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 edab5ae..de90b18 100644 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt @@ -67,7 +67,7 @@ class CourseGrabRepository(private val preferencesHelper: PreferencesHelper) { envelope == null || data == null -> Result.failure( ApiException( if (httpResponse.isSuccessful) "Unreadable response from server" - else "Server error (${httpResponse.code})" + else "HTTP error (${httpResponse.code})" ) )