From b1e1fe54252ec321b0ff9378c6191f1ff6f7bfa4 Mon Sep 17 00:00:00 2001 From: Abhijeet Viswa Date: Wed, 1 Sep 2021 17:56:10 +0530 Subject: [PATCH 1/2] Add: Option to unenrol from courses This commit introduces a bunch of code to add the ability to unenrol students from courses. The method is hacky, involving creating an HTTP session using a private token, followed by emulating a HTML form submit to do the unenrolment. Unfortunately, Moodle doesn't have a webservice to allow for unenrolment. :( --- .../cms/fragments/CourseContentFragment.kt | 23 ++++- .../crux/bphc/cms/helper/CourseManager.kt | 52 +++++++++++ .../bphc/cms/helper/CourseRequestHandler.java | 38 +++++++- .../bphc/cms/helper/UserSessionManager.kt | 93 +++++++++++++++++++ .../java/crux/bphc/cms/models/UserAccount.kt | 40 +++++++- .../bphc/cms/models/core/AutoLoginDetail.kt | 8 ++ .../java/crux/bphc/cms/network/APIClient.java | 30 +++--- .../crux/bphc/cms/network/MoodleServices.java | 49 ++++++++++ app/src/main/res/drawable/ic_web.xml | 2 +- app/src/main/res/menu/course_details_menu.xml | 5 +- app/src/main/res/values/strings.xml | 3 +- 11 files changed, 316 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/crux/bphc/cms/helper/CourseManager.kt create mode 100644 app/src/main/java/crux/bphc/cms/helper/UserSessionManager.kt create mode 100644 app/src/main/java/crux/bphc/cms/models/core/AutoLoginDetail.kt diff --git a/app/src/main/java/crux/bphc/cms/fragments/CourseContentFragment.kt b/app/src/main/java/crux/bphc/cms/fragments/CourseContentFragment.kt index 244bc413..b8d38d4d 100644 --- a/app/src/main/java/crux/bphc/cms/fragments/CourseContentFragment.kt +++ b/app/src/main/java/crux/bphc/cms/fragments/CourseContentFragment.kt @@ -13,6 +13,7 @@ import androidx.core.text.HtmlCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout @@ -22,6 +23,7 @@ import crux.bphc.cms.app.Urls import crux.bphc.cms.core.FileManager import crux.bphc.cms.fragments.MoreOptionsFragment.OptionsViewModel import crux.bphc.cms.helper.CourseDataHandler +import crux.bphc.cms.helper.CourseManager import crux.bphc.cms.helper.CourseRequestHandler import crux.bphc.cms.interfaces.ClickListener import crux.bphc.cms.interfaces.CourseContent @@ -43,8 +45,10 @@ import kotlin.collections.ArrayList */ class CourseContentFragment : Fragment() { private lateinit var fileManager: FileManager - private lateinit var courseDataHandler: CourseDataHandler private lateinit var realm: Realm + private lateinit var courseDataHandler: CourseDataHandler + private lateinit var courseRequestHandler: CourseRequestHandler + private lateinit var courseManager: CourseManager var courseId: Int = 0 private lateinit var courseName: String @@ -86,9 +90,13 @@ class CourseContentFragment : Fragment() { courseName = courseDataHandler.getCourseName(courseId) courseSections = courseDataHandler.getCourseData(courseId) + courseRequestHandler = CourseRequestHandler() + fileManager = FileManager(requireActivity(), courseName) { setCourseContentsOnAdapter() } fileManager.registerDownloadReceiver() + courseManager = CourseManager(courseId, courseRequestHandler) + setHasOptionsMenu(true) } @@ -325,7 +333,6 @@ class CourseContentFragment : Fragment() { private fun refreshContent(contextUrl: String = "") { CoroutineScope(Dispatchers.IO).launch { - val courseRequestHandler = CourseRequestHandler() var sections = mutableListOf() try { sections = courseRequestHandler.getCourseDataSync(courseId) @@ -391,16 +398,22 @@ class CourseContentFragment : Fragment() { setCourseContentsOnAdapter() Toast.makeText(activity, "Marked all as read", Toast.LENGTH_SHORT).show() return true - } - if (item.itemId == R.id.action_open_in_browser) { + } else if (item.itemId == R.id.action_open_in_browser) { Utils.openURLInBrowser(requireActivity(), Urls.getCourseUrl(courseId).toString()) + return true; + } else { + viewLifecycleOwner.lifecycleScope.launch { + courseManager.unenrolCourse(requireContext()) + } } + + return super.onOptionsItemSelected(item) } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - inflater.inflate(R.menu.course_details_menu, menu) super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.course_details_menu, menu) } override fun onDestroy() { diff --git a/app/src/main/java/crux/bphc/cms/helper/CourseManager.kt b/app/src/main/java/crux/bphc/cms/helper/CourseManager.kt new file mode 100644 index 00000000..2c056056 --- /dev/null +++ b/app/src/main/java/crux/bphc/cms/helper/CourseManager.kt @@ -0,0 +1,52 @@ +package crux.bphc.cms.helper + +import android.content.Context +import android.util.Log +import android.widget.Toast +import androidx.annotation.UiThread +import crux.bphc.cms.models.course.Course +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class CourseManager(val courseId: Int, val courseRequestHandler: CourseRequestHandler) { + + var startedUnenrol: Boolean = false + + @UiThread + suspend fun unenrolCourse(context: Context): Boolean { + /* Guard against multiple presses */ + if (startedUnenrol) return false + startedUnenrol = true + + if (!UserSessionManager.hasValidSession()) { + if (!UserSessionManager.createUserSession()) { + Toast.makeText( + context, + "Failed to create session. Try logging out and back in!", + Toast.LENGTH_LONG + ).show() + startedUnenrol = false + return false + } + } + + val ret = withContext(Dispatchers.IO) { + val idsess = courseRequestHandler.getEnrolIdSessKey(courseId) ?: + return@withContext false + val enrolId = idsess.first ?: return@withContext false + val sessKey = idsess.second ?: return@withContext false + + courseRequestHandler.unenrolSelf(enrolId, sessKey) + } + + if (ret) { + Toast.makeText(context, "Sucessfully unenroled from course!", Toast.LENGTH_LONG).show() + } else { + Toast.makeText(context, "Failed to unenrol from course!", Toast.LENGTH_LONG ).show() + } + + startedUnenrol = false + return ret + } + +} \ No newline at end of file diff --git a/app/src/main/java/crux/bphc/cms/helper/CourseRequestHandler.java b/app/src/main/java/crux/bphc/cms/helper/CourseRequestHandler.java index 8314028e..e31104d1 100644 --- a/app/src/main/java/crux/bphc/cms/helper/CourseRequestHandler.java +++ b/app/src/main/java/crux/bphc/cms/helper/CourseRequestHandler.java @@ -2,6 +2,7 @@ import android.content.Context; import android.util.Log; +import android.util.Pair; import android.widget.Toast; import androidx.annotation.NonNull; @@ -29,6 +30,7 @@ import crux.bphc.cms.models.forum.ForumData; import crux.bphc.cms.network.APIClient; import crux.bphc.cms.network.MoodleServices; +import kotlin.text.Regex; import okhttp3.ResponseBody; import retrofit2.Call; import retrofit2.Callback; @@ -246,7 +248,6 @@ public List getForumDiscussions(int moduleId) { return null; } - @NotNull public List getForumDicussionsSync(int moduleId) throws IOException { Call call = moodleServices.getForumDiscussions(userAccount.getToken(), moduleId, 0, 0); @@ -276,6 +277,41 @@ public void onFailure(@NotNull Call call, @NotNull Throwable t) { }); } + @Nullable + public Pair getEnrolIdSessKey(int courseId) { + String sessionCookie = UserSessionManager.INSTANCE.getFormattedSessionCookie(); + Call call = moodleServices.viewCoursePage(sessionCookie, courseId); + try { + Response response = call.execute(); + String rawHtml = response.body().string(); + if (rawHtml == null) { + return null; + } + + String enrolId = new Regex("enrolid=([0-9]+)").find(rawHtml, 0) + .getGroupValues().get(1); + String sessKey = new Regex("sesskey=(\\w+)").find(rawHtml, 0) + .getGroupValues().get(1); + return new Pair(enrolId, sessKey); + } catch (IOException | IndexOutOfBoundsException | NullPointerException e) { + Log.e(TAG, "Failed in getEnrolIdSessKey", e); + return null; + } + } + + public boolean unenrolSelf(String enrolId, String sessKey) { + String sessionCookie = UserSessionManager.INSTANCE.getFormattedSessionCookie(); + Call call = moodleServices.selfUnenrolCourse(sessionCookie, enrolId, sessKey); + try{ + Response response = call.execute(); + /* We have no way of verifying unenrolment success */ + return true; + } catch (IOException e) { + Log.e(TAG, "Failed in unenrolSelf", e); + return false; + } + } + //This method resolves the names of files with same names private List resolve(List courseSections) { List contents = new ArrayList<>(); diff --git a/app/src/main/java/crux/bphc/cms/helper/UserSessionManager.kt b/app/src/main/java/crux/bphc/cms/helper/UserSessionManager.kt new file mode 100644 index 00000000..e2beb003 --- /dev/null +++ b/app/src/main/java/crux/bphc/cms/helper/UserSessionManager.kt @@ -0,0 +1,93 @@ +package crux.bphc.cms.helper + +import android.util.Log +import crux.bphc.cms.models.UserAccount +import crux.bphc.cms.network.APIClient +import crux.bphc.cms.network.MoodleServices +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException + +object UserSessionManager { + /** + * Create an HTTP session using a private token. + */ + suspend fun createUserSession(): Boolean { + val token = UserAccount.token + val privateToken = UserAccount.privateToken + if (privateToken.isEmpty()) { + return false; + } + + val retrofit = APIClient.getRetrofitInstance(false) + val moodleServices = retrofit.create(MoodleServices::class.java) + + /* + * We first use the private token to get an autologin key. This key + * then can be used to generate a session cookie. + */ + val detail = withContext(Dispatchers.IO) { + val call = moodleServices.autoLoginGetKey(token, privateToken) ?: return@withContext null + try { + val response = call.execute() + if (!response.isSuccessful()) { + return@withContext null + } + + return@withContext response.body() + } catch (e: IOException) { + Log.e("UserAccount", "IOException when fetching autologin key", e) + return@withContext null + } + } ?: return false + + return withContext(Dispatchers.IO) { + val call = moodleServices.autoLoginWithKey(detail.autoLoginUrl, UserAccount.userID, + detail.key) ?: return@withContext false + + try { + val response = call.execute() + if (!response.raw().isRedirect) { + return@withContext false + } + + /* + * The server responds with 'Set-Cookie' headers for each + * cookie it wants to set. Attributes are separated by ;. + * The first attribute is the 'cookie-name=value' pair + * we require. + */ + val cookies = response.raw().headers("Set-Cookie") ?: return@withContext false + for (cookie in cookies) { + val kv = cookie.trim().split(";")[0].split("=") + if (kv[0] != "MoodleSession") { + continue + } + + UserAccount.sessionCookie = kv[1] + UserAccount.sessionCookieGenEpoch = System.currentTimeMillis() / 1000; + return@withContext true + } + return@withContext false + } catch(e: IOException) { + Log.e("UserAccount", "IOException when attempting to autologin", e) + return@withContext false + } + } + } + + fun getFormattedSessionCookie(): String { + return "MoodleSession=${UserAccount.sessionCookie}" + } + + fun hasValidSession(): Boolean { + /* + * We have no real way of determining if the session is still valid. + * Futher more, Moodle rate limits autologin to 6 minutes. We simply + * assume a session generated more than 6 minutes ago is invalid. + * A less naive implementation could perform an actual HTTP request + * and determine if the session is valid or not. + */ + return UserAccount.sessionCookieGenEpoch + (6 * 60) > System.currentTimeMillis() / 1000; + } +} \ No newline at end of file diff --git a/app/src/main/java/crux/bphc/cms/models/UserAccount.kt b/app/src/main/java/crux/bphc/cms/models/UserAccount.kt index 35e38a80..bbb79e2a 100644 --- a/app/src/main/java/crux/bphc/cms/models/UserAccount.kt +++ b/app/src/main/java/crux/bphc/cms/models/UserAccount.kt @@ -1,8 +1,16 @@ package crux.bphc.cms.models import android.content.Context +import android.os.Build +import android.util.Log import crux.bphc.cms.app.MyApplication import crux.bphc.cms.models.core.UserDetail +import crux.bphc.cms.network.APIClient +import crux.bphc.cms.network.MoodleServices +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.time.Instant /** * @author Harshit Agarwal (16-Dec-2016) @@ -20,6 +28,34 @@ object UserAccount { val token: String get() = prefs.getString("token", "") ?: "" + val privateToken: String + get() = prefs.getString("privateToken", "") ?: "" + + /** + * A session cookie that is associated with the current user. + * The session may have expired and the cookie may be invalid. + */ + var sessionCookie: String + get() = prefs.getString("sessionCookie", "") ?: "" + set(value) { + prefs.edit() + .putString("sessionCookie", value) + .apply() + } + + /** + * The approx. UNIX epoch at which this session cookie was + * generated. Can be used to roughly estimate the validity + * of the session cookie. + */ + var sessionCookieGenEpoch: Long + get() = prefs.getLong("sessionCookieGenEpoch", -1) + set(value) { + prefs.edit() + .putLong("sessionCookieGenEpoch", value) + .apply() + } + val username: String get() = prefs.getString("username", "") ?: "" @@ -48,9 +84,7 @@ object UserAccount { .putString("token", userDetail.token) // the private token can be used to create an http sesion // check /admin/tool/mobile/autologin.php - .putString("privateToken", userDetail.privateToken) // the private token can be used to - // create an http session from - // che + .putString("privateToken", userDetail.privateToken) .putString("firstname", userDetail.firstName) .putString("lastname", userDetail.lastName) .putString("userpictureurl", userDetail.userPictureUrl) diff --git a/app/src/main/java/crux/bphc/cms/models/core/AutoLoginDetail.kt b/app/src/main/java/crux/bphc/cms/models/core/AutoLoginDetail.kt new file mode 100644 index 00000000..c15ebdf3 --- /dev/null +++ b/app/src/main/java/crux/bphc/cms/models/core/AutoLoginDetail.kt @@ -0,0 +1,8 @@ +package crux.bphc.cms.models.core + +import com.google.gson.annotations.SerializedName + +data class AutoLoginDetail( + @SerializedName("key") val key: String = "", + @SerializedName("autologinurl") val autoLoginUrl: String = "", +) diff --git a/app/src/main/java/crux/bphc/cms/network/APIClient.java b/app/src/main/java/crux/bphc/cms/network/APIClient.java index 6e0ba6a1..f2cf9cb3 100644 --- a/app/src/main/java/crux/bphc/cms/network/APIClient.java +++ b/app/src/main/java/crux/bphc/cms/network/APIClient.java @@ -13,27 +13,27 @@ public class APIClient { - private static Retrofit retrofit = null; - private static final HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(); private static final OkHttpClient.Builder builder = new OkHttpClient.Builder(); private APIClient() { } - public static Retrofit getRetrofitInstance() { - if (retrofit == null) { - interceptor.setLevel(HttpLoggingInterceptor.Level.BODY); - if (BuildConfig.DEBUG) { - builder.addInterceptor(interceptor); - } - - retrofit = new Retrofit.Builder() - .addConverterFactory(GsonConverterFactory.create()) - .baseUrl(Urls.MOODLE_URL.toString()) - .client(builder.build()) - .build(); + public static Retrofit getRetrofitInstance(boolean followRedirects) { + interceptor.setLevel(HttpLoggingInterceptor.Level.BODY); + if (BuildConfig.DEBUG) { + builder.addInterceptor(interceptor); } - return retrofit; + builder.followRedirects(followRedirects); + + return new Retrofit.Builder() + .addConverterFactory(GsonConverterFactory.create()) + .baseUrl(Urls.MOODLE_URL.toString()) + .client(builder.build()) + .build(); + } + + public static Retrofit getRetrofitInstance() { + return APIClient.getRetrofitInstance(false); } } diff --git a/app/src/main/java/crux/bphc/cms/network/MoodleServices.java b/app/src/main/java/crux/bphc/cms/network/MoodleServices.java index a7aebce6..c80c86ee 100644 --- a/app/src/main/java/crux/bphc/cms/network/MoodleServices.java +++ b/app/src/main/java/crux/bphc/cms/network/MoodleServices.java @@ -1,18 +1,29 @@ package crux.bphc.cms.network; +import android.annotation.TargetApi; + import org.jetbrains.annotations.NotNull; import java.util.List; +import crux.bphc.cms.models.core.AutoLoginDetail; import crux.bphc.cms.models.core.UserDetail; +import okhttp3.Response; import okhttp3.ResponseBody; import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.Field; +import retrofit2.http.FormUrlEncoded; import retrofit2.http.GET; +import retrofit2.http.Header; +import retrofit2.http.Headers; +import retrofit2.http.POST; import retrofit2.http.Query; import crux.bphc.cms.models.course.CourseSection; import crux.bphc.cms.models.enrol.SelfEnrol; import crux.bphc.cms.models.forum.ForumData; import crux.bphc.cms.models.enrol.CourseSearch; +import retrofit2.http.Url; /** * Interface of Retrofit compatible API calls. @@ -107,4 +118,42 @@ Call registerUserDevice(@Query("wstoken") @NotNull String token, Call deregisterUserDevice(@Query("wstoken") @NotNull String token, @Query("uuid") @NotNull String uuid, @Query("appid") @NotNull String appId); + + /** + * Endpoint to obtain an autologin key using private token. This endpoint + * requires the private token to not be a GET parameter with the user agent + * set to 'MoodleMobile'. + */ + @FormUrlEncoded + @Headers("User-Agent: MoodleMobile") + @POST("webservice/rest/server.php?wsfunction=tool_mobile_get_autologin_key&moodlewsrestformat=json") + Call autoLoginGetKey(@Query("wstoken") @NotNull String token, + @Field("privatetoken") @NotNull String privateToken); + + /** + * Use an auto-login key to create a user session. The auto-login endpoint + * is dynamic and will be returned with the auto-login key. The endpoint + * will redirect to the wwwroot. The 'Set-Cookie' header of this redirect + * response will contain the session token for further session auth based + * requests. + */ + @GET + Call autoLoginWithKey(@Url @NotNull String autoLoginUrl, + @Query("userid") int userId, + @Query("key") @NotNull String key); + + @GET("course/view.php") + Call viewCoursePage(@Header("Cookie") String moodleSession, + @Query("id") int courseId); + + /** + * Webpage used to unenrol a user from a course they self-enroled in. + * enrolId and sessKey should be obtained before hand using the + * {@see MoodleServices#viewCoursePage} endpoint. sessKey is a CSRF + * token embedded into links and forms by Moodle with each request. + */ + @GET("enrol/self/unenrolself.php?confirm=1") + Call selfUnenrolCourse(@Header("Cookie") String moodleSession, + @Query("enrolid") String enrolId, + @Query("sesskey") String sessKey); } diff --git a/app/src/main/res/drawable/ic_web.xml b/app/src/main/res/drawable/ic_web.xml index e7453253..a47582d4 100644 --- a/app/src/main/res/drawable/ic_web.xml +++ b/app/src/main/res/drawable/ic_web.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24" - android:tint="?attr/colorControlNormal"> + android:tint="?iconTintColor"> diff --git a/app/src/main/res/menu/course_details_menu.xml b/app/src/main/res/menu/course_details_menu.xml index a0d65ada..5c05fc67 100644 --- a/app/src/main/res/menu/course_details_menu.xml +++ b/app/src/main/res/menu/course_details_menu.xml @@ -6,9 +6,12 @@ android:title="@string/open_course_website_on_browser" android:icon="@drawable/ic_web" app:showAsAction="ifRoom" /> + - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 173043df..48e37e15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -33,7 +33,7 @@ open page in browser - mark all as read + Mark all as read Failed to load course content.\nTap to retry. @@ -114,4 +114,5 @@ No items 1 item, %1$s %1$d items, %2$s + Unenrol From d2fd5e70e8e821a1c1dda4749f9bec83fef0faec Mon Sep 17 00:00:00 2001 From: Saket Singh Date: Mon, 10 Jan 2022 00:07:13 +0530 Subject: [PATCH 2/2] Fix course list not refreshing and course page not directing to my courses fragment after unenrolment 2 fixes were carried out on pull request #336 , namely :- 1)Course page still opens even after unenrolment. 2)Local course list does not refresh after unenrolment. --- .../crux/bphc/cms/activities/MainActivity.kt | 12 ++ .../cms/fragments/CourseContentFragment.kt | 145 +++++++++++++----- .../bphc/cms/fragments/MyCoursesFragment.kt | 98 ++++++++---- .../crux/bphc/cms/helper/CourseManager.kt | 2 +- app/src/main/res/values/strings.xml | 1 + 5 files changed, 185 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/crux/bphc/cms/activities/MainActivity.kt b/app/src/main/java/crux/bphc/cms/activities/MainActivity.kt index 106e18a1..fc6973d4 100644 --- a/app/src/main/java/crux/bphc/cms/activities/MainActivity.kt +++ b/app/src/main/java/crux/bphc/cms/activities/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle +import android.util.Log import android.view.MenuItem import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -120,6 +121,17 @@ class MainActivity : AppCompatActivity() { resolveIntent() resolveModuleLinkShare() + + //checks if the attempt to unenrol was successful and instructs the MyCoursesFragment() to refresh + val fragment = MyCoursesFragment() + val unenrolResult = intent.getBooleanExtra(CourseContentFragment.INTENT_UNENROL_RESULT,false) + if(unenrolResult){ + val bundle = Bundle() + bundle.putBoolean(getString(R.string.refreshRequired),true) + fragment.arguments = bundle + supportFragmentManager.beginTransaction().replace(R.id.content_frame, fragment).commit() + } + } override fun onBackPressed() { diff --git a/app/src/main/java/crux/bphc/cms/fragments/CourseContentFragment.kt b/app/src/main/java/crux/bphc/cms/fragments/CourseContentFragment.kt index b8d38d4d..fb51a04b 100644 --- a/app/src/main/java/crux/bphc/cms/fragments/CourseContentFragment.kt +++ b/app/src/main/java/crux/bphc/cms/fragments/CourseContentFragment.kt @@ -36,9 +36,18 @@ import io.realm.Realm import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.IOException import java.util.* import kotlin.collections.ArrayList +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import androidx.fragment.app.setFragmentResult +import crux.bphc.cms.activities.CourseDetailActivity +import crux.bphc.cms.activities.MainActivity + /** * @author Siddhant Kumar Patel, Abhijeet Viswa @@ -68,8 +77,8 @@ class CourseContentFragment : Fragment() { val contents = ArrayList() courseSections.stream().filter { courseSection: CourseSection -> !(courseSection.modules.isEmpty() - && courseSection.summary.isEmpty() - && courseSection.name.matches(Regex("^Topic \\d*$"))) + && courseSection.summary.isEmpty() + && courseSection.name.matches(Regex("^Topic \\d*$"))) }.forEach { courseSection: CourseSection -> contents.add(courseSection) contents.addAll(courseSection.modules) @@ -105,15 +114,18 @@ class CourseContentFragment : Fragment() { requireActivity().title = courseDataHandler.getCourseNameForActionBarTitle(courseId) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?): View? { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { return inflater.inflate(R.layout.fragment_course_section, container, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - moreOptionsViewModel = ViewModelProvider(requireActivity()).get(OptionsViewModel::class.java) + moreOptionsViewModel = + ViewModelProvider(requireActivity()).get(OptionsViewModel::class.java) empty = view.findViewById(R.id.empty) as TextView mSwipeRefreshLayout = view.findViewById(R.id.swipeRefreshLayout) @@ -125,8 +137,10 @@ class CourseContentFragment : Fragment() { refreshContent() } - adapter = CourseContentAdapter(requireActivity(), courseContents, fileManager, - moduleClickWrapperClickListener, moduleMoreOptionsClickListener) + adapter = CourseContentAdapter( + requireActivity(), courseContents, fileManager, + moduleClickWrapperClickListener, moduleMoreOptionsClickListener + ) recyclerView.adapter = adapter recyclerView.layoutManager = LinearLayoutManager(activity) recyclerView.setItemViewCacheSize(10) @@ -158,16 +172,22 @@ class CourseContentFragment : Fragment() { /* Set up our options and their handlers */ val options = ArrayList() val observer: Observer = if (downloaded) { - options.addAll(listOf( + options.addAll( + listOf( MoreOptionsFragment.Option(0, "View", R.drawable.eye), - MoreOptionsFragment.Option(1, "Re-Download", R.drawable.outline_file_download_24), + MoreOptionsFragment.Option( + 1, + "Re-Download", + R.drawable.outline_file_download_24 + ), MoreOptionsFragment.Option(2, "Share", R.drawable.ic_share), MoreOptionsFragment.Option(3, "Mark as Unread", R.drawable.eye_off) - )) + ) + ) if (module.modType === Module.Type.RESOURCE) { options.add(MoreOptionsFragment.Option(4, "Properties", R.drawable.ic_info)) } - Observer label@ { option: MoreOptionsFragment.Option? -> + Observer label@{ option: MoreOptionsFragment.Option? -> if (option == null) return@label when (option.id) { 0 -> fileManager.openModuleContent(content!!) @@ -175,8 +195,10 @@ class CourseContentFragment : Fragment() { if (!module.isDownloadable) { return@label } - Toast.makeText(activity, "Downloading file - " + content!!.fileName, - Toast.LENGTH_SHORT).show() + Toast.makeText( + activity, "Downloading file - " + content!!.fileName, + Toast.LENGTH_SHORT + ).show() fileManager.downloadModuleContent(content, module) } 2 -> fileManager.shareModuleContent(content!!) @@ -190,16 +212,25 @@ class CourseContentFragment : Fragment() { moreOptionsViewModel.clearSelection() } } else { - options.addAll(listOf( - MoreOptionsFragment.Option(0, "Download", R.drawable.outline_file_download_24), + options.addAll( + listOf( + MoreOptionsFragment.Option( + 0, + "Download", + R.drawable.outline_file_download_24 + ), MoreOptionsFragment.Option(1, "Share", R.drawable.ic_share), MoreOptionsFragment.Option(2, "Mark as Unread", R.drawable.eye_off) - )) + ) + ) if (module.modType === Module.Type.RESOURCE) { - options.add(MoreOptionsFragment.Option( - 3, "Properties", R.drawable.ic_info)) + options.add( + MoreOptionsFragment.Option( + 3, "Properties", R.drawable.ic_info + ) + ) } - Observer label@ { option: MoreOptionsFragment.Option? -> + Observer label@{ option: MoreOptionsFragment.Option? -> if (option == null) return@label val activity = activity when (option.id) { @@ -226,8 +257,10 @@ class CourseContentFragment : Fragment() { val activity = activity if (activity != null) { val moreOptionsFragment = MoreOptionsFragment.newInstance(module.name, options) - moreOptionsFragment.show(requireActivity().supportFragmentManager, - moreOptionsFragment.tag) + moreOptionsFragment.show( + requireActivity().supportFragmentManager, + moreOptionsFragment.tag + ) moreOptionsViewModel.selection.observe(activity, observer) courseDataHandler.markModuleAsRead(module); adapter.notifyItemChanged(position) @@ -253,10 +286,10 @@ class CourseContentFragment : Fragment() { ForumFragment.newInstance(courseId, module.instance, courseName) else FolderModuleFragment.newInstance(module.instance, courseName) activity.supportFragmentManager - .beginTransaction() - .addToBackStack(null) - .replace(R.id.course_section_enrol_container, fragment, "Announcements") - .commit() + .beginTransaction() + .addToBackStack(null) + .replace(R.id.course_section_enrol_container, fragment, "Announcements") + .commit() } Module.Type.LABEL -> { val desc = module.description @@ -264,12 +297,23 @@ class CourseContentFragment : Fragment() { val alertDialog: AlertDialog.Builder = if (UserAccount.isDarkModeEnabled) { AlertDialog.Builder(activity, R.style.Theme_AppCompat_Dialog_Alert) } else { - AlertDialog.Builder(activity, R.style.Theme_AppCompat_Light_Dialog_Alert) + AlertDialog.Builder( + activity, + R.style.Theme_AppCompat_Light_Dialog_Alert + ) } - val htmlDescription = HtmlCompat.fromHtml(module.description, - HtmlCompat.FROM_HTML_MODE_COMPACT) - val descriptionWithOutExtraSpace = htmlDescription.toString().trim { it <= ' ' } - alertDialog.setMessage(htmlDescription.subSequence(0, descriptionWithOutExtraSpace.length)) + val htmlDescription = HtmlCompat.fromHtml( + module.description, + HtmlCompat.FROM_HTML_MODE_COMPACT + ) + val descriptionWithOutExtraSpace = + htmlDescription.toString().trim { it <= ' ' } + alertDialog.setMessage( + htmlDescription.subSequence( + 0, + descriptionWithOutExtraSpace.length + ) + ) alertDialog.setNegativeButton("Close", null) alertDialog.show() } @@ -278,8 +322,10 @@ class CourseContentFragment : Fragment() { if (fileManager.isModuleContentDownloaded(content)) { fileManager.openModuleContent(content) } else { - Toast.makeText(getActivity(), "Downloading file - " + content.fileName, - Toast.LENGTH_SHORT).show() + Toast.makeText( + getActivity(), "Downloading file - " + content.fileName, + Toast.LENGTH_SHORT + ).show() fileManager.downloadModuleContent(content, module) } } @@ -299,7 +345,12 @@ class CourseContentFragment : Fragment() { val sharingIntent = Intent(Intent.ACTION_SEND) sharingIntent.type = "text/plain" sharingIntent.putExtra(Intent.EXTRA_TEXT, toShare) - if (context != null) requireContext().startActivity(Intent.createChooser(sharingIntent, null)) + if (context != null) requireContext().startActivity( + Intent.createChooser( + sharingIntent, + null + ) + ) } @@ -322,7 +373,9 @@ class CourseContentFragment : Fragment() { private fun showSectionsOrEmpty() { - if (courseSections.stream().anyMatch { section: CourseSection -> !section.modules.isEmpty() }) { + if (courseSections.stream() + .anyMatch { section: CourseSection -> !section.modules.isEmpty() } + ) { empty.visibility = View.GONE recyclerView.visibility = View.VISIBLE return @@ -344,7 +397,8 @@ class CourseContentFragment : Fragment() { empty.visibility = View.VISIBLE recyclerView.visibility = View.GONE - Toast.makeText(activity, "Unable to connect to server!", Toast.LENGTH_SHORT).show() + Toast.makeText(activity, "Unable to connect to server!", Toast.LENGTH_SHORT) + .show() mSwipeRefreshLayout.isRefreshing = false } } @@ -360,13 +414,13 @@ class CourseContentFragment : Fragment() { for (module in modules) { if (module.modType == Module.Type.FORUM) { val discussions = courseRequestHandler - .getForumDicussionsSync(module.instance) + .getForumDicussionsSync(module.instance) for (d in discussions) { d.forumId = module.instance } val newDiscussions = courseDataHandler - .setForumDiscussions(module.instance, discussions) + .setForumDiscussions(module.instance, discussions) if (newDiscussions.size > 0) { courseDataHandler.markModuleAsUnread(module); } @@ -392,6 +446,7 @@ class CourseContentFragment : Fragment() { } override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.mark_all_as_read) { courseDataHandler.markCourseAsRead(courseId) courseSections = courseDataHandler.getCourseData(courseId) @@ -402,11 +457,22 @@ class CourseContentFragment : Fragment() { Utils.openURLInBrowser(requireActivity(), Urls.getCourseUrl(courseId).toString()) return true; } else { + viewLifecycleOwner.lifecycleScope.launch { - courseManager.unenrolCourse(requireContext()) + val retResult = courseManager.unenrolCourse(requireContext()) + withContext(Dispatchers.Main) { + if (retResult) { + val intent = Intent(requireActivity(), MainActivity::class.java) + + intent.putExtra(INTENT_UNENROL_RESULT,true) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + + startActivity(intent) + } + } } - } + } return super.onOptionsItemSelected(item) } @@ -428,6 +494,7 @@ class CourseContentFragment : Fragment() { private const val TOKEN_KEY = "token" private const val COURSE_ID_KEY = "id" private const val CONTEXT_URL_KEY = "contextUrl" + const val INTENT_UNENROL_RESULT = "unenrolResult" @JvmStatic fun newInstance(token: String, courseId: Int, contextUrl: String): CourseContentFragment { diff --git a/app/src/main/java/crux/bphc/cms/fragments/MyCoursesFragment.kt b/app/src/main/java/crux/bphc/cms/fragments/MyCoursesFragment.kt index 260d46a0..cd910281 100644 --- a/app/src/main/java/crux/bphc/cms/fragments/MyCoursesFragment.kt +++ b/app/src/main/java/crux/bphc/cms/fragments/MyCoursesFragment.kt @@ -13,6 +13,7 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -46,9 +47,9 @@ class MyCoursesFragment : Fragment() { // Activity result launchers private val courseDetailActivityLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - mAdapter.filterCoursesByName(courses, searchCourseET.text.toString()) - } + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + mAdapter.filterCoursesByName(courses, searchCourseET.text.toString()) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -60,8 +61,10 @@ class MyCoursesFragment : Fragment() { requireActivity().title = "My Courses" } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle?): View? { + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { realm = Realm.getDefaultInstance() return inflater.inflate(R.layout.fragment_my_courses, container, false) } @@ -82,7 +85,8 @@ class MyCoursesFragment : Fragment() { realm.close() CoroutineScope(Dispatchers.Main).launch { - Toast.makeText(requireActivity(), "Marked all as read", Toast.LENGTH_SHORT).show() + Toast.makeText(requireActivity(), "Marked all as read", Toast.LENGTH_SHORT) + .show() mAdapter.courses = this@MyCoursesFragment.courseDataHandler.courseList } } @@ -109,18 +113,29 @@ class MyCoursesFragment : Fragment() { return@ClickListener true } + //ensures refreshing of courses after user unenrols from one + val refreshRequired = arguments?.getBoolean(getString(R.string.refreshRequired), false) + if (refreshRequired == true) { + swipeRefreshLayout.isRefreshing = true + refreshCourses() + } + + mAdapter.downloadClickListener = ClickListener { `object`: Any, position: Int -> val course = `object` as Course if (course.downloadStatus != -1) return@ClickListener false course.downloadStatus = 0 mAdapter.notifyItemChanged(position) - val courseDownloader = CourseDownloader(activity, courseDataHandler.getCourseName(course.id)) + + val courseDownloader = + CourseDownloader(activity, courseDataHandler.getCourseName(course.id)) courseDownloader.setDownloadCallback(object : CourseDownloader.DownloadCallback { override fun onCourseDataDownloaded() { course.downloadedFiles = courseDownloader.getDownloadedContentCount(course.id) course.totalFiles = courseDownloader.getTotalContentCount(course.id) if (course.totalFiles == course.downloadedFiles) { - Toast.makeText(activity, "All files already downloaded", Toast.LENGTH_SHORT).show() + Toast.makeText(activity, "All files already downloaded", Toast.LENGTH_SHORT) + .show() course.downloadStatus = -1 } else { course.downloadStatus = 1 @@ -140,7 +155,7 @@ class MyCoursesFragment : Fragment() { override fun onFailure() { Toast.makeText(activity, "Check your internet connection", Toast.LENGTH_SHORT) - .show() + .show() course.downloadStatus = -1 mAdapter.notifyItemChanged(position) courseDownloader.unregisterReceiver() @@ -165,12 +180,15 @@ class MyCoursesFragment : Fragment() { searchCourseET.setText("") searchIcon.setImageResource(R.drawable.ic_search) searchIcon.setOnClickListener(null) - val inputManager = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) - as? InputMethodManager + val inputManager = + requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) + as? InputMethodManager val currentFocus = requireActivity().currentFocus if (currentFocus != null) { - inputManager?.hideSoftInputFromWindow(currentFocus.windowToken, - InputMethodManager.HIDE_NOT_ALWAYS) + inputManager?.hideSoftInputFromWindow( + currentFocus.windowToken, + InputMethodManager.HIDE_NOT_ALWAYS + ) } } } else { @@ -209,12 +227,15 @@ class MyCoursesFragment : Fragment() { val courseDataHandler = CourseDataHandler(realm) courseDataHandler.replaceCourses(courseList) realm.close() - checkEmpty() + withContext(Dispatchers.Main){ + checkEmpty() + } updateCourseContent() } catch (e: Exception) { Log.e(TAG, "", e) withContext(Dispatchers.Main) { - Toast.makeText(requireActivity(), "Error: ${e.message}", Toast.LENGTH_SHORT).show() + Toast.makeText(requireActivity(), "Error: ${e.message}", Toast.LENGTH_SHORT) + .show() if (e is InvalidTokenException) { UserUtils.logout() UserUtils.clearBackStackAndLaunchTokenActivity(requireActivity()) @@ -264,8 +285,10 @@ class MyCoursesFragment : Fragment() { val message: String = if (coursesUpdated == 0) { getString(R.string.upToDate) } else { - resources.getQuantityString(R.plurals.noOfCoursesUpdated, coursesUpdated, - coursesUpdated) + resources.getQuantityString( + R.plurals.noOfCoursesUpdated, coursesUpdated, + coursesUpdated + ) } Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() } @@ -278,8 +301,8 @@ class MyCoursesFragment : Fragment() { } private inner class Adapter constructor( - val context: Context, - courseList: List + val context: Context, + courseList: List ) : RecyclerView.Adapter() { private val inflater: LayoutInflater = LayoutInflater.from(context) @@ -336,7 +359,8 @@ class MyCoursesFragment : Fragment() { val count = courseDataHandler.getUnreadCount(course.id) itemView.course_number.text = course.courseName[0] itemView.course_name.text = name - itemView.unread_count.text = DecimalFormat.getIntegerInstance().format(count.toLong()) + itemView.unread_count.text = + DecimalFormat.getIntegerInstance().format(count.toLong()) itemView.unread_count.isVisible = count != 0 itemView.mark_as_read_button.isVisible = count != 0 itemView.favorite_button.setImageResource(if (course.isFavorite) R.drawable.ic_fav_filled else R.drawable.ic_fav_outlined) @@ -344,19 +368,21 @@ class MyCoursesFragment : Fragment() { fun confirmDownloadCourse() { MaterialAlertDialogBuilder(context) - .setTitle("Confirm Download") - .setMessage("Are you sure you want to all the contents of this course?") - .setPositiveButton("Yes") { _: DialogInterface?, _: Int -> - if (downloadClickListener != null) { - val pos = layoutPosition - if (!downloadClickListener!!.onClick(courses[pos], pos)) { - Toast.makeText(activity, "Download already in progress", - Toast.LENGTH_SHORT).show() - } + .setTitle("Confirm Download") + .setMessage("Are you sure you want to all the contents of this course?") + .setPositiveButton("Yes") { _: DialogInterface?, _: Int -> + if (downloadClickListener != null) { + val pos = layoutPosition + if (!downloadClickListener!!.onClick(courses[pos], pos)) { + Toast.makeText( + activity, "Download already in progress", + Toast.LENGTH_SHORT + ).show() } } - .setNegativeButton("Cancel", null) - .show() + } + .setNegativeButton("Cancel", null) + .show() } fun markCourseAsRead() { @@ -373,7 +399,8 @@ class MyCoursesFragment : Fragment() { course.isFavorite = isFavourite courses = sortCourses(courses) notifyDataSetChanged() - val toast = if (isFavourite) getString(R.string.added_to_favorites) else getString(R.string.removed_from_favorites) + val toast = + if (isFavourite) getString(R.string.added_to_favorites) else getString(R.string.removed_from_favorites) Toast.makeText(activity, toast, Toast.LENGTH_SHORT).show() } @@ -386,7 +413,12 @@ class MyCoursesFragment : Fragment() { } itemView.mark_as_read_button.setOnClickListener { markCourseAsRead() } - itemView.favorite_button.setOnClickListener { setFavoriteStatus(layoutPosition, !courses[layoutPosition].isFavorite) } + itemView.favorite_button.setOnClickListener { + setFavoriteStatus( + layoutPosition, + !courses[layoutPosition].isFavorite + ) + } itemView.download_image.setOnClickListener { confirmDownloadCourse() } } } diff --git a/app/src/main/java/crux/bphc/cms/helper/CourseManager.kt b/app/src/main/java/crux/bphc/cms/helper/CourseManager.kt index 2c056056..f1a0b634 100644 --- a/app/src/main/java/crux/bphc/cms/helper/CourseManager.kt +++ b/app/src/main/java/crux/bphc/cms/helper/CourseManager.kt @@ -40,7 +40,7 @@ class CourseManager(val courseId: Int, val courseRequestHandler: CourseRequestHa } if (ret) { - Toast.makeText(context, "Sucessfully unenroled from course!", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Successfully unenroled from course!", Toast.LENGTH_LONG).show() } else { Toast.makeText(context, "Failed to unenrol from course!", Toast.LENGTH_LONG ).show() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f23aa662..40f238e2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,4 +116,5 @@ 1 item, %1$s %1$d items, %2$s Unenrol + refreshRequired