remove grades/qispos

This commit is contained in:
Jannik 2022-12-22 17:39:42 +01:00
parent 3e52061a20
commit 521045e0dd
Signed by: Seil0
GPG Key ID: E8459F3723C52C24
13 changed files with 2 additions and 1104 deletions

View File

@ -94,7 +94,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
getString(R.string.intent_action_mensaFragment) -> activeFragment = MensaFragment() getString(R.string.intent_action_mensaFragment) -> activeFragment = MensaFragment()
getString(R.string.intent_action_timetableFragment) -> activeFragment = TimetableFragment() getString(R.string.intent_action_timetableFragment) -> activeFragment = TimetableFragment()
getString(R.string.intent_action_moodleFragment) -> activeFragment = MoodleFragment() getString(R.string.intent_action_moodleFragment) -> activeFragment = MoodleFragment()
getString(R.string.intent_action_gradesFragment) -> activeFragment = GradesFragment()
else -> activeFragment = HomeFragment() else -> activeFragment = HomeFragment()
} }
@ -155,7 +154,6 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
R.id.nav_mensa -> MensaFragment() R.id.nav_mensa -> MensaFragment()
R.id.nav_timetable -> TimetableFragment() R.id.nav_timetable -> TimetableFragment()
R.id.nav_moodle -> MoodleFragment() R.id.nav_moodle -> MoodleFragment()
R.id.nav_grades -> GradesFragment()
R.id.nav_settings -> SettingsFragment() R.id.nav_settings -> SettingsFragment()
else -> HomeFragment() else -> HomeFragment()
} }

View File

@ -1,99 +0,0 @@
/**
* ProjectLaogai
*
* Copyright 2019-2020 <seil0@mosad.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.seil0.projectlaogai.controller
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences
import org.mosad.seil0.projectlaogai.util.GradeSubject
import org.mosad.seil0.projectlaogai.worker.GradesUpdateWorker
import java.util.concurrent.TimeUnit
class GradesController {
/**
* show the difference between 2 grades sets
*/
fun diffGrades(origMap: Map<String, ArrayList<GradeSubject>>, diffMap: Map<String, ArrayList<GradeSubject>>): ArrayList<GradeSubject> {
val diff = ArrayList<GradeSubject>()
diffMap.values.forEach { semester ->
// if it's the same, no need to compare
if (!origMap.containsValue(semester)) {
semester.forEach { gradeSubject ->
// for each of the grades, check if it differs from the origMap
if (origMap.containsKey(gradeSubject.semester)) {
// a new or changed subject
if (gradeSubject !in origMap[gradeSubject.semester]!!) {
diff.add(gradeSubject)
}
} else {
// a new semester
diff.add(gradeSubject)
}
}
}
}
return diff
}
companion object {
/**
* stop the grades background sync, if the sync interval is greater than 0
* start it again with the new interval
*/
fun updateBackgroundSync(context: Context) {
stopBackgroundSync(context)
// if interval is not 0, start background Sync
if (Preferences.gradesSyncInterval > 0)
startBackgroundSync(context)
}
/**
* start a new periodic worker GradesUpdateWorker
*/
fun startBackgroundSync(context: Context) {
val work = PeriodicWorkRequestBuilder<GradesUpdateWorker>(
Preferences.gradesSyncInterval.toLong(),
TimeUnit.HOURS
).build()
val workManager = WorkManager.getInstance(context)
workManager.enqueueUniquePeriodicWork("GradesUpdateWorker", ExistingPeriodicWorkPolicy.REPLACE, work)
}
/**
* cancel GradesUpdateWorker
*/
fun stopBackgroundSync(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork("GradesUpdateWorker")
}
}
}

View File

@ -1,240 +0,0 @@
/**
* ProjectLaogai
*
* Copyright 2019-2020 <seil0@mosad.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.seil0.projectlaogai.controller
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.jsoup.HttpStatusException
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.preferences.EncryptedPreferences
import org.mosad.seil0.projectlaogai.util.GradeSubject
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.*
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
/**
* Parse the qispos site the get all needed data for the grades fragment
*/
class QISPOSParser(val context: Context) {
private val className = this.javaClass.name
private val baseURL = "https://notenverwaltung.hs-offenburg.de"
private val loginPath = "/qispos/rds?state=user&type=1&category=auth.login&startpage=portal.vm&breadCrumbSource=portal"
/**
* check if qispos is available
* @return a http status code, 999 if no HttpStatusException supplied
*/
fun checkQISPOSStatus(): Int {
return runBlocking {
withContext(Dispatchers.IO) {
val socketFactory = createSSLSocketFactory()
try {
val res = Jsoup.connect(baseURL + loginPath)
.sslSocketFactory(socketFactory)
.followRedirects(true)
.referrer("https://notenverwaltung.hs-offenburg.de/qispos/rds?state=user&type=0")
.execute()
Log.i(className, "Qispos status is: ${res.statusCode()}")
res.statusCode()
} catch (exHttp: HttpStatusException) {
Log.w(className, "Qispos status is: ${exHttp.statusCode}")
exHttp.statusCode
} catch (ex: Exception) {
Log.e(className, "Error while checking status", ex)
999
}
}
}
}
/**
* parse the html from readGrades()
* @return a SortedMap, each entry is a semester, each semester has a ArrayList with subjects
*/
fun parseGrades(): SortedMap<String, ArrayList<GradeSubject>> {
val gradesMap = HashMap<String, ArrayList<GradeSubject>>()
val gradesListHtml = readGrades()
gradesListHtml?.select("table > tbody > tr")?.forEach {
val row = it.select("td.tabelle1_alignleft,td.tabelle1_aligncenter,td.tabelle1_alignright")
// only real subjects will be selected
if(row.size >= 6 && row[0].text().length >=7) {
val subject = GradeSubject(
id = row[0].text(),
name = row[1].text(),
semester = row[2].text(),
grade = if (row[3].text().isNotEmpty()) row[3].text() else row[4].text(),
credits = row[5].text()
)
if (gradesMap.containsKey(subject.semester)) {
gradesMap[subject.semester]!!.add(subject)
} else {
gradesMap[subject.semester] = arrayListOf(subject)
}
}
}
return gradesMap.toSortedMap(compareBy<String>{
val oText = it.substringAfter(" ")
// if WS, substring before / and add 0.5 to compareBy
if (oText.contains("/")) {
oText.substringBefore("/").toInt() + 0.5
} else {
oText.toDouble()
}
}.thenBy { it })
}
/**
* read the grades html from qispos
* @return the grades list as html element or null
*/
private fun readGrades(): Element?{
val credentials = EncryptedPreferences.readCredentials(context)
val username = credentials.first.substringBefore("@")
val password = credentials.second
return runBlocking {
withContext(Dispatchers.IO) {
try {
val socketFactory = createSSLSocketFactory()
// login, asdf = username, fdsa = password, wtf
val list = mapOf(
Pair("asdf", username),
Pair("fdsa", password),
Pair("submit", "Anmelden")
)
// login and get session cookies
val res = Jsoup.connect(baseURL + loginPath)
.sslSocketFactory(socketFactory)
.followRedirects(true)
.referrer("https://notenverwaltung.hs-offenburg.de/qispos/rds?state=user&type=0")
.data(list)
.postDataCharset("UTF-8")
.execute()
Log.i(className, "Login status is: ${res.statusCode()} (${res.statusMessage()})")
val loginCookies = res.cookies()
// grades root document and url
val rootHtml =Jsoup.parse(res.body())
val gradesRootLink =
rootHtml.select("li.menueListStyle > a.auflistung").last()!!.attr("abs:href")
// parse grades url
val gradesHtml = Jsoup.connect(gradesRootLink)
.sslSocketFactory(socketFactory)
.followRedirects(true)
.cookies(loginCookies)
.referrer("https://notenverwaltung.hs-offenburg.de/qispos/rds?state=user&type=0")
.get()
val gradesNextLink = gradesHtml.select("li.treelist > a.regular").attr("abs:href")
val gradesNextHtml = Jsoup.connect(gradesNextLink)
.sslSocketFactory(socketFactory)
.followRedirects(true)
.cookies(loginCookies)
.referrer("https://notenverwaltung.hs-offenburg.de/qispos/rds?state=user&type=0")
.get()
val gradesListLink = gradesNextHtml.selectFirst("li.treelist > ul > li")!!.selectFirst("a")!!.attr("abs:href")
// get the grades list
val gradesListHtml = Jsoup.connect(gradesListLink)
.sslSocketFactory(socketFactory)
.followRedirects(true)
.cookies(loginCookies)
.referrer("https://notenverwaltung.hs-offenburg.de/qispos/rds?state=user&type=0")
.get()
Log.i(className, "Read html length: ${gradesListHtml.body().html().length}")
gradesListHtml.body()
} catch (ex: Exception) {
Log.e(className, "Error while loading Qispos", ex)
null
}
}
}
}
/**
* since the HS has a fucked up tls setup we need to work around that
*/
private fun createSSLSocketFactory(): SSLSocketFactory {
// Load CAs from an InputStream
// (could be from a resource or ByteArrayInputStream or ...)
val cf: CertificateFactory = CertificateFactory.getInstance("X.509")
val caInput = context.resources.openRawResource(R.raw.notenverwaltung_hs_offenburg_de)
val ca = caInput.use {
cf.generateCertificate(it) as X509Certificate
}
// Create a KeyStore containing our trusted CAs
val keyStoreType = KeyStore.getDefaultType()
val keyStore = KeyStore.getInstance(keyStoreType).apply {
load(null, null)
setCertificateEntry("ca", ca)
}
// Create a TrustManager that trusts the CAs inputStream our KeyStore
val tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm()
val tmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply {
init(keyStore)
}
// Create an SSLContext that uses our TrustManager
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, tmf.trustManagers, null)
}
return sslContext.socketFactory
}
}

View File

@ -24,18 +24,14 @@ package org.mosad.seil0.projectlaogai.controller.cache
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.MasterKey
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.mosad.seil0.projectlaogai.controller.QISPOSParser
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences import org.mosad.seil0.projectlaogai.controller.preferences.Preferences
import org.mosad.seil0.projectlaogai.controller.TCoRAPIController import org.mosad.seil0.projectlaogai.controller.TCoRAPIController
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.coursesCacheTime import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.coursesCacheTime
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.gradesCacheTime
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.mensaCacheTime import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.mensaCacheTime
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.timetableCacheTime import org.mosad.seil0.projectlaogai.controller.preferences.Preferences.timetableCacheTime
import org.mosad.seil0.projectlaogai.util.* import org.mosad.seil0.projectlaogai.util.*
@ -202,23 +198,6 @@ class CacheController(private val context: Context) {
} }
} }
/**
* update the encrypted grades file
*/
fun updateGrades(context: Context): Job {
val file = File(context.filesDir, "grades_encrypted")
val parser = QISPOSParser(context)
return CoroutineScope(Dispatchers.IO).launch {
if (parser.checkQISPOSStatus() == 200) {
// save cache file and update time
saveEncrypted(context, file, Gson().toJson(parser.parseGrades()))
gradesCacheTime = System.currentTimeMillis() / 1000
Preferences.save(context)
}
}
}
/** /**
* save changes in lessonMap and subjectMap, * save changes in lessonMap and subjectMap,
* called on addSubject or removeSubject * called on addSubject or removeSubject
@ -240,28 +219,6 @@ class CacheController(private val context: Context) {
Log.e(className, "failed to write file \"${file.absoluteFile}\"", ex) Log.e(className, "failed to write file \"${file.absoluteFile}\"", ex)
} }
} }
private fun saveEncrypted(context: Context, file: File, text: String) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedFile = EncryptedFile.Builder(
context,
file,
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
// only write one file at a time
synchronized(this) {
if (file.exists()) { file.delete() }
encryptedFile.openFileOutput().bufferedWriter().use {
it.write(text)
}
}
}
} }
/** /**
@ -411,48 +368,4 @@ class CacheController(private val context: Context) {
} }
/**
* read the encrypted grades file, don't keep them
* in CacheController for security reasons
* @return the grades as SortedMap if the file exists, else a empty SortedMap
*/
fun readGrades(): SortedMap<String, java.util.ArrayList<GradeSubject>> {
val file = File(context.filesDir, "grades_encrypted")
// if the file does not exit, try creating it
if (!file.exists()) {
runBlocking { updateGrades(context) }
}
if (file.exists()) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedFile = EncryptedFile.Builder(
context,
file,
masterKey,
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()
val map: HashMap<String, ArrayList<GradeSubject>> = encryptedFile.openFileInput().bufferedReader().use {
GsonBuilder().create()
.fromJson(it, object : TypeToken<HashMap<String, ArrayList<GradeSubject>>>() {}.type)
}
// same sorting as qispos parser
return map.toSortedMap(compareBy<String>{
val oText = it.substringAfter(" ")
if (oText.contains("/")) {
oText.substringBefore("/").toInt() + 0.5
} else {
oText.toDouble()
}
}.thenBy { it })
}
return sortedMapOf()
}
} }

View File

@ -1,228 +0,0 @@
/**
* ProjectLaogai
*
* Copyright 2019-2020 <seil0@mosad.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.seil0.projectlaogai.fragments
import android.graphics.Rect
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.res.ResourcesCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.*
import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.QISPOSParser
import org.mosad.seil0.projectlaogai.controller.cache.CacheController
import org.mosad.seil0.projectlaogai.controller.preferences.EncryptedPreferences
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences
import org.mosad.seil0.projectlaogai.databinding.FragmentGradesBinding
import org.mosad.seil0.projectlaogai.uicomponents.DayCardView
import org.mosad.seil0.projectlaogai.uicomponents.GradeLinearLayout
import org.mosad.seil0.projectlaogai.uicomponents.dialogs.LoginDialog
import kotlin.system.measureTimeMillis
/**
* The grades fragment class
* contains all needed parts to display and the grades screen
*/
class GradesFragment : Fragment() {
private lateinit var parser: QISPOSParser
private lateinit var binding: FragmentGradesBinding
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentGradesBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.refreshLayoutGrades.setProgressBackgroundColorSchemeColor(Preferences.themeSecondary)
initActions()
parser = QISPOSParser(requireContext())// init the parser
if (checkCredentials()) {
lifecycleScope.launch(Dispatchers.Default) {
// if the cache is older than 24hr, update blocking
val currentTime = System.currentTimeMillis() / 1000
withContext(Dispatchers.Main) {
if ((currentTime - Preferences.gradesCacheTime) > 86400 && checkQisposStatus()) {
binding.refreshLayoutGrades.isRefreshing = true
CacheController.updateGrades(requireContext()).join()
}
}
addGrades()
}
}
}
/**
* initialize the actions
*/
private fun initActions() = lifecycleScope.launch(Dispatchers.Default) {
binding.refreshLayoutGrades.setOnRefreshListener {
binding.linLayoutGrades.removeAllViews() // clear layout
// TODO add loading textView
lifecycleScope.launch(Dispatchers.Default) {
CacheController.updateGrades(requireContext()).join()
addGrades()
}
}
}
/**
* check if the credentials are set, if not show a login dialog
*/
private fun checkCredentials(): Boolean {
val credentials = EncryptedPreferences.readCredentials(requireContext())
var credentialsPresent = false
// if there is no password set, show the login dialog
if (credentials.first.isEmpty() || credentials.second.isEmpty()) {
LoginDialog(this.requireContext())
.positiveButton {
EncryptedPreferences.saveCredentials(email, password, context)
addGrades()
}
.negativeButton {
binding.txtViewLoading.text = resources.getString(R.string.credentials_missing)
}
.show {
email = EncryptedPreferences.email
password = ""
}
} else {
credentialsPresent = true
}
return credentialsPresent
}
/**
* check if qispos is available, if not show an error
*/
private fun checkQisposStatus(): Boolean {
val statusCode = parser.checkQISPOSStatus()
// show error if the status code is not 200
if (statusCode != 200) {
val infoText = resources.getString(when(statusCode) {
503 -> R.string.qispos_unavailable
else -> R.string.qispos_generic_error
})
val img = ResourcesCompat.getDrawable(resources, R.drawable.ic_error_outline_black_24dp, null)?.apply {
bounds = Rect(0, 0, 75, 75)
}
binding.txtViewLoading.apply {
text = infoText
setCompoundDrawables(null, null, null, img)
}
}
return statusCode == 200
}
/**
* add the grades to the layout, async
* TODO this is slow as hell
*/
private fun addGrades() = lifecycleScope.launch(Dispatchers.Default) {
val addGradesTime = measureTimeMillis {
val grades = CacheController(requireContext()).readGrades()
withContext(Dispatchers.Main) {
binding.linLayoutGrades.removeAllViews() // clear layout
}
// TODO this loop takes 3/4 of the time
// for each semester add a new card
grades.forEach { semester ->
val usedSubjects = ArrayList<String>()
val semesterCard = DayCardView(requireContext())
semesterCard.setDayHeading(semester.key)
// for each subject add a new linLayout
semester.value.forEachIndexed { index, subject ->
if (usedSubjects.contains(subject.name)) {
return@forEachIndexed
}
// get the first sub subjects
val subSubject = semester.value.firstOrNull {
it.name.contains(subject.name) && it.name != subject.name
}
// if sub subject is not null, add it to used subjects
subSubject?.let {
usedSubjects.add(it.name)
}
val subjectLayout = GradeLinearLayout(context).set {
subjectName = subject.name
grade = subject.grade
subSubjectName = subSubject?.name.toString()
subGrade = subSubject?.grade.toString()
}
// disable sub-subject if not set
if (subSubject == null)
subjectLayout.disableSubSubject()
// disable divider if last element
if (index == semester.value.lastIndex || semester.value.indexOf(subSubject) == semester.value.lastIndex)
subjectLayout.disableDivider()
semesterCard.getLinLayoutDay().addView(subjectLayout)
}
// without context we can't access the view
withContext(Dispatchers.Main) {
binding.linLayoutGrades.addView(semesterCard)
}
}
val txtViewLegal = TextView(context).apply {
text = resources.getString(R.string.without_guarantee)
textAlignment = View.TEXT_ALIGNMENT_CENTER
}
// stop refreshing and show legal warning
withContext(Dispatchers.Main) {
binding.linLayoutGrades.addView(txtViewLegal)
binding.refreshLayoutGrades.isRefreshing = false
}
}
Log.i(javaClass.name, "added grades in $addGradesTime ms")
}
}

View File

@ -34,7 +34,6 @@ import com.afollestad.materialdialogs.MaterialDialog
import com.afollestad.materialdialogs.WhichButton import com.afollestad.materialdialogs.WhichButton
import com.afollestad.materialdialogs.actions.getActionButton import com.afollestad.materialdialogs.actions.getActionButton
import com.afollestad.materialdialogs.list.listItemsMultiChoice import com.afollestad.materialdialogs.list.listItemsMultiChoice
import com.afollestad.materialdialogs.list.listItemsSingleChoice
import de.psdev.licensesdialog.LicensesDialog import de.psdev.licensesdialog.LicensesDialog
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
@ -42,7 +41,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.mosad.seil0.projectlaogai.BuildConfig import org.mosad.seil0.projectlaogai.BuildConfig
import org.mosad.seil0.projectlaogai.R import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.GradesController
import org.mosad.seil0.projectlaogai.controller.cache.CacheController import org.mosad.seil0.projectlaogai.controller.cache.CacheController
import org.mosad.seil0.projectlaogai.controller.cache.CacheController.Companion.coursesList import org.mosad.seil0.projectlaogai.controller.cache.CacheController.Companion.coursesList
import org.mosad.seil0.projectlaogai.controller.cache.TimetableController import org.mosad.seil0.projectlaogai.controller.cache.TimetableController
@ -80,11 +78,6 @@ class SettingsFragment : Fragment() {
binding.textUser.text = EncryptedPreferences.email.ifEmpty { resources.getString(R.string.sample_user) } binding.textUser.text = EncryptedPreferences.email.ifEmpty { resources.getString(R.string.sample_user) }
binding.textCourse.text = Preferences.course.courseName binding.textCourse.text = Preferences.course.courseName
binding.textAboutDesc.text = resources.getString(R.string.about_version, BuildConfig.VERSION_NAME, getString(R.string.build_time)) binding.textAboutDesc.text = resources.getString(R.string.about_version, BuildConfig.VERSION_NAME, getString(R.string.build_time))
binding.textGradesSyncDesc.text = if (Preferences.gradesSyncInterval == 0) {
resources.getString(R.string.grades_sync_desc_never)
} else {
resources.getString(R.string.grades_sync_desc, Preferences.gradesSyncInterval)
}
binding.switchBuffet.isChecked = Preferences.showBuffet // init switch binding.switchBuffet.isChecked = Preferences.showBuffet // init switch
val outValue = TypedValue() val outValue = TypedValue()
@ -278,38 +271,6 @@ class SettingsFragment : Fragment() {
// } // }
// } // }
binding.linLayoutGradesSync.setOnClickListener {
val items = resources.getStringArray(R.array.syncInterval).toList()
val initial = when (Preferences.gradesSyncInterval) {
1 -> 1
3 -> 2
6 -> 3
12 -> 4
else -> 0
}
MaterialDialog(requireContext())
.title(R.string.grades_sync)
.listItemsSingleChoice(items = items, initialSelection = initial) { _, index, _ ->
val interval = when (index) {
1 -> 1
2 -> 3
3 -> 6
4 -> 12
else -> 0
}
Preferences.saveGradesSync(requireContext(), interval)
GradesController.updateBackgroundSync(requireContext())
binding.textGradesSyncDesc.text = if (Preferences.gradesSyncInterval == 0) {
resources.getString(R.string.grades_sync_desc_never)
} else {
resources.getString(R.string.grades_sync_desc, Preferences.gradesSyncInterval)
}
}.show()
}
binding.switchBuffet.setOnClickListener { binding.switchBuffet.setOnClickListener {
Preferences.saveShowBuffet(requireContext(), binding.switchBuffet.isChecked) Preferences.saveShowBuffet(requireContext(), binding.switchBuffet.isChecked)
} }

View File

@ -1,56 +0,0 @@
/**
* ProjectLaogai
*
* Copyright 2019-2020 <seil0@mosad.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.seil0.projectlaogai.uicomponents
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import org.mosad.seil0.projectlaogai.databinding.LinearlayoutGradeBinding
class GradeLinearLayout(context: Context?): LinearLayout(context) {
private val binding = LinearlayoutGradeBinding.inflate(LayoutInflater.from(context), this, true)
var subjectName = ""
var grade = ""
var subSubjectName = ""
var subGrade = ""
fun set(func: GradeLinearLayout.() -> Unit): GradeLinearLayout = apply {
func()
binding.textSubject.text = subjectName
binding.textGrade.text = grade
binding.textSubSubject.text = subSubjectName
binding.textSubGrade.text = subGrade
}
fun disableDivider() {
binding.dividerGrade.visibility = View.GONE
}
fun disableSubSubject() {
binding.linearSubSubject.visibility = View.GONE
}
}

View File

@ -1,112 +0,0 @@
/**
* ProjectLaogai
*
* Copyright 2019-2020 <seil0@mosad.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.seil0.projectlaogai.worker
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.Worker
import androidx.work.WorkerParameters
import kotlinx.coroutines.runBlocking
import org.mosad.seil0.projectlaogai.MainActivity
import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.GradesController
import org.mosad.seil0.projectlaogai.controller.cache.CacheController
import org.mosad.seil0.projectlaogai.controller.preferences.EncryptedPreferences
import org.mosad.seil0.projectlaogai.util.NotificationUtils
import org.mosad.seil0.projectlaogai.util.NotificationUtils.Companion.CHANNEL_ID_GRADES
class GradesUpdateWorker(val context: Context, params: WorkerParameters): Worker(context, params) {
override fun doWork(): Result {
// check if credentials are present, if not do nothing
val credentials = EncryptedPreferences.readCredentials(context)
if (credentials.first.isEmpty() || credentials.second.isEmpty()) {
return Result.success()
}
val notificationIdChecking = NotificationUtils.getId()
val builderChecking = NotificationCompat.Builder(context, CHANNEL_ID_GRADES)
.setSmallIcon(R.drawable.ic_grading_black_24dp)
.setContentTitle(context.getString(R.string.notification_grades))
.setContentText(context.getString(R.string.notification_grades_updating_desc))
.setNotificationSilent()
NotificationManagerCompat.from(context).apply {
notify(notificationIdChecking, builderChecking.build())
}
// get old grades
val oldGrades = CacheController(context).readGrades()
// get update from qispos
runBlocking { CacheController.updateGrades(context).join() }
val newGrades = CacheController(context).readGrades()
// check for changes
val diff = GradesController().diffGrades(oldGrades, newGrades)
// show message
if (diff.isNotEmpty()) {
val text = if (diff.size < 2) {
context.getString(R.string.notification_grades_single_desc, diff.first().name)
} else {
context.getString(R.string.notification_grades_multiple_desc, diff.first().name, (diff.size - 1))
}
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
action = context.getString(R.string.intent_action_gradesFragment)
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(context, 0, intent, 0)
val builder = NotificationCompat.Builder(context, CHANNEL_ID_GRADES)
.setSmallIcon(R.drawable.ic_grading_black_24dp)
.setContentTitle(context.getString(R.string.notification_grades))
.setContentText(text)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
// if there are multiple subjects, use BigText
if (diff.size > 1)
builder.setStyle(NotificationCompat.BigTextStyle().bigText(text))
NotificationManagerCompat.from(context).apply {
notify(NotificationUtils.getId(), builder.build())
}
}
// remove scanning notification
NotificationManagerCompat.from(context).apply {
cancel(notificationIdChecking)
}
return Result.success()
}
}

View File

@ -1,70 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/linLayout_grade"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="7dp"
android:paddingTop="2dp"
android:paddingRight="7dp"
android:paddingBottom="3dp">
<LinearLayout
android:id="@+id/linear_subject"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<TextView
android:id="@+id/text_subject"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/sample_subject"
android:textSize="15sp" />
<TextView
android:id="@+id/text_grade"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:text="@string/sample_grade"
android:textAlignment="textEnd"
android:textSize="15sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/linear_sub_subject"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:paddingStart="7dp"
android:paddingTop="3dp"
android:paddingEnd="0dp">
<TextView
android:id="@+id/text_sub_subject"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/sample_sub_subject" />
<TextView
android:id="@+id/text_sub_grade"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:text="@string/sample_grade_state"
android:textAlignment="textEnd" />
</LinearLayout>
<View
android:id="@+id/divider_grade"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="3dp"
android:background="?dividerColor" />
</LinearLayout>

View File

@ -1,38 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?themePrimary"
tools:context=".fragments.GradesFragment">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refreshLayout_Grades"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:id="@+id/scrollView_Grades"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/linLayout_Grades"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/txtView_Loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="100dp"
android:padding="7dp"
android:text="@string/loading_from_hs"
android:textAlignment="center"
android:textSize="15sp" />
</LinearLayout>
</ScrollView>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</FrameLayout>

View File

@ -18,7 +18,8 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:orientation="vertical"
android:paddingBottom="11dp">
<androidx.cardview.widget.CardView <androidx.cardview.widget.CardView
android:id="@+id/cardView_Info" android:id="@+id/cardView_Info"
@ -340,35 +341,6 @@
android:layout_height="1dp" android:layout_height="1dp"
android:background="?android:attr/listDivider" /> android:background="?android:attr/listDivider" />
<LinearLayout
android:id="@+id/linLayout_GradesSync"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:selectableItemBackground"
android:orientation="vertical"
android:padding="7dp">
<TextView
android:id="@+id/text_grades_sync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/grades_sync"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/text_grades_sync_desc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/grades_sync_desc" />
</LinearLayout>
<View
android:id="@+id/divider8"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?android:attr/listDivider" />
<androidx.appcompat.widget.SwitchCompat <androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_buffet" android:id="@+id/switch_buffet"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -20,10 +20,6 @@
android:id="@+id/nav_moodle" android:id="@+id/nav_moodle"
android:icon="@drawable/ic_school_black_24dp" android:icon="@drawable/ic_school_black_24dp"
android:title="@string/moodle"/> android:title="@string/moodle"/>
<item
android:id="@+id/nav_grades"
android:icon="@drawable/ic_grading_black_24dp"
android:title="@string/grades" />
<item <item
android:id="@+id/nav_settings" android:id="@+id/nav_settings"
android:icon="@drawable/ic_settings_black_24dp" android:icon="@drawable/ic_settings_black_24dp"

View File

@ -1,99 +0,0 @@
/**
* ProjectLaogai
*
* Copyright 2019-2020 <seil0@mosad.xyz>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*
*/
package org.mosad.seil0.projectlaogai
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.mosad.seil0.projectlaogai.controller.GradesController
import org.mosad.seil0.projectlaogai.util.GradeSubject
import java.io.File
import java.io.FileReader
class GradesControllerTest {
@Test
fun diffGrades_noDiff() {
val origFile = File(GradesControllerTest::class.java.getResource("/grades_orig.json")?.path!!)
val diffFile = File(GradesControllerTest::class.java.getResource("/grades_orig.json")?.path!!)
val mapA: HashMap<String, ArrayList<GradeSubject>> = FileReader(origFile).use {
GsonBuilder().create().fromJson(it, object : TypeToken<HashMap<String, ArrayList<GradeSubject>>>() {}.type)
}
val mapB: HashMap<String, ArrayList<GradeSubject>> = FileReader(diffFile).use {
GsonBuilder().create().fromJson(it, object : TypeToken<HashMap<String, ArrayList<GradeSubject>>>() {}.type)
}
val exp = ArrayList<GradeSubject>()
val ret = GradesController().diffGrades(mapA, mapB)
Assertions.assertEquals(exp, ret)
}
@Test
fun diffGrades_diffSubject() {
val origFile = File(GradesControllerTest::class.java.getResource("/grades_orig.json")?.path!!)
val diffFile = File(GradesControllerTest::class.java.getResource("/grades_diff_subject.json")?.path!!)
val mapA: HashMap<String, ArrayList<GradeSubject>> = FileReader(origFile).use {
GsonBuilder().create().fromJson(it, object : TypeToken<HashMap<String, ArrayList<GradeSubject>>>() {}.type)
}
val mapB: HashMap<String, ArrayList<GradeSubject>> = FileReader(diffFile).use {
GsonBuilder().create().fromJson(it, object : TypeToken<HashMap<String, ArrayList<GradeSubject>>>() {}.type)
}
val exp = arrayListOf(
GradeSubject("AI-3010", "Computernetze", "WiSe 18/19", "0,7", "2,0"),
GradeSubject("AI-3020", "Datenbanksysteme 1", "WiSe 18/19", "1,7", "2,0"),
GradeSubject("AI-3025", "Praktikum Datenbanksysteme", "WiSe 18/19", "bestanden", "3,0")
)
val ret = GradesController().diffGrades(mapA, mapB)
Assertions.assertEquals(exp, ret)
}
@Test
fun diffGrades_diffSemester() {
val origFile = File(GradesControllerTest::class.java.getResource("/grades_orig.json")?.path!!)
val diffFile = File(GradesControllerTest::class.java.getResource("/grades_diff_semester.json")?.path!!)
val mapA: HashMap<String, ArrayList<GradeSubject>> = FileReader(origFile).use {
GsonBuilder().create().fromJson(it, object : TypeToken<HashMap<String, ArrayList<GradeSubject>>>() {}.type)
}
val mapB: HashMap<String, ArrayList<GradeSubject>> = FileReader(diffFile).use {
GsonBuilder().create().fromJson(it, object : TypeToken<HashMap<String, ArrayList<GradeSubject>>>() {}.type)
}
val exp = arrayListOf(
GradeSubject("AI-2010", "Mathemaik 7", "SoSe 19", "1,7", "4,0"),
GradeSubject("AI-2015", "Praktikum Mathemaik 7", "SoSe 19", "bestanden", "1,0")
)
val ret = GradesController().diffGrades(mapA, mapB)
Assertions.assertEquals(exp, ret)
}
}