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/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..41edf71 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,10 @@ 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) } + .onFailure { Log.d(TAG, "Could not resume previous session", it) } } } } @@ -191,30 +169,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.") + } } } @@ -237,23 +198,11 @@ class LoginActivity : AppCompatActivity() { Snackbar.make(binding.loginRootView, message, Snackbar.LENGTH_LONG).show() } - private fun sendRegistrationToServer(token: String?) { - val sendDeviceToken = Endpoint.deviceToken( - preferencesHelper.sessionToken.toString(), - token.toString() - ) - + private fun sendRegistrationToServer(token: String) { 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) + .onSuccess { Log.d(TAG, "sendRegistrationTokenToServer($token)") } + .onFailure { Log.w(TAG, "Failed to register device token", it) } } } @@ -279,19 +228,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..e1f46e7 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)) } /** @@ -70,20 +65,9 @@ class NotificationService : FirebaseMessagingService() { * * @param token The new token. */ - private fun sendRegistrationToServer(token: String?) { - val sendDeviceToken = Endpoint.deviceToken( - preferencesHelper.sessionToken.toString(), - token.toString() - ) - + private fun sendRegistrationToServer(token: String) { CoroutineScope(Dispatchers.Main).launch { - val typeToken = object : TypeToken>() {}.type - withContext(Dispatchers.IO) { - Request.makeRequest>( - sendDeviceToken.okHttpRequest(), - typeToken - ) - } + repository.sendDeviceToken(token) } } diff --git a/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt b/app/src/main/java/com/cornellappdev/coursegrab/SearchActivity.kt index 228bef2..2c73cfd 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 @@ -10,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 @@ -17,16 +20,10 @@ 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.Job import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext class SearchActivity : AppCompatActivity() { private lateinit var binding: ActivitySearchBinding @@ -35,10 +32,13 @@ 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)) } + /** 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) @@ -59,16 +59,13 @@ 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 - ) + // The query is no longer searchable, so nothing in flight is current. + searchJob?.cancel() + showEmptyState( + icon = R.drawable.ic_status_warning, + title = R.string.requires_longer_search, + subtitle = 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) } } @@ -76,19 +73,17 @@ 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 + searchJob?.cancel() + searchJob = lifecycleScope.launch { + val courseList = repository.searchCourses(query).getOrElse { error -> + Log.e(TAG, "Search failed for query \"$query\"", error) + showEmptyState( + icon = R.drawable.ic_status_warning, + title = R.string.search_failed, + subtitle = R.string.search_failed_subtext ) - }!!.data.courses - - if (binding.editTextSearch.text.toString() != query) return@launch + } // Results Courses Adapter searchViewManager = LinearLayoutManager(this@SearchActivity) @@ -100,21 +95,35 @@ 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( + icon = R.drawable.ic_status_closed, + title = R.string.no_courses_alert, + subtitle = 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 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, 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( private val resultsCourses: List, private val context: Context @@ -150,4 +159,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..de90b18 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/coursegrab/networking/CourseGrabRepository.kt @@ -0,0 +1,96 @@ +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 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 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) + + 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})" + ) + ) + + !envelope.success -> Result.failure( + 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 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/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 deleted file mode 100644 index dc8f7e0..0000000 --- a/app/src/main/java/com/cornellappdev/coursegrab/networking/Request.kt +++ /dev/null @@ -1,61 +0,0 @@ -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 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) - } - } - }) - - 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..4fd10d9 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(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 = 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