ProjectLaogai/app/src/main/java/org/mosad/seil0/projectlaogai/controller/cache/CacheController.kt

458 lines
17 KiB
Kotlin

/**
* 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.cache
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedFile
import androidx.security.crypto.MasterKey
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.*
import org.mosad.seil0.projectlaogai.controller.QISPOSParser
import org.mosad.seil0.projectlaogai.controller.preferences.Preferences
import org.mosad.seil0.projectlaogai.controller.TCoRAPIController
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.timetableCacheTime
import org.mosad.seil0.projectlaogai.util.*
import java.io.*
import java.util.*
import kotlin.collections.ArrayList
import kotlin.collections.HashMap
/**
* The cacheController reads and updates the cache files.
* It contains the courseList and mensaMenu object, all timetable objects
* are located in TimetableController.
*/
class CacheController(private val context: Context) {
private val className = this.javaClass.name
init {
val cal = Calendar.getInstance()
val currentDay = Calendar.getInstance().get(Calendar.DAY_OF_WEEK)
val currentTime = System.currentTimeMillis() / 1000
// check if we need to update the mensa data before displaying it
cal.time = Date(mensaCacheTime * 1000)
// if a) it's monday and the last cache update was on sunday or b) the cache is older than 24hr, update blocking
if ((currentDay == Calendar.MONDAY && cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) || (currentTime - mensaCacheTime) > 86400) {
Log.i(className, "Update mensa blocking")
CoroutineScope(Dispatchers.Default).launch { updateMensaMenu(context).join() }
}
// check if we need to update the timetable before displaying it
cal.time = Date(timetableCacheTime * 1000)
// if a) it`s monday and the last cache update was not on a sunday or b) the cache is older than 24hr, update blocking
if ((currentDay == Calendar.MONDAY && cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) || (currentTime - timetableCacheTime) > 86400) {
Log.i(className, "Updating timetable after sunday!")
CoroutineScope(Dispatchers.Default).launch {
val threads = listOf(
updateTimetable(Preferences.course.courseName, 0, context),
updateTimetable(Preferences.course.courseName, 1, context)
)
threads.joinAll()
}
}
updateCourseList(context)
readStartCache(Preferences.course.courseName) // initially read values from cache
// check if an update is necessary, not blocking
if (currentTime - coursesCacheTime > 86400)
updateCourseList(context)
if (currentTime - mensaCacheTime > 10800)
updateMensaMenu(context)
if (currentTime - timetableCacheTime > 10800)
TimetableController.update(context)
}
companion object {
private const val className = "CacheController"
var coursesList = ArrayList<Course>()
var mensaMenu = MensaMenu()
/**
* update the course list, async
*/
fun updateCourseList(context: Context): Job {
val file = File(context.filesDir, "courses.json")
var courseListUp = CoursesList()
return CoroutineScope(Dispatchers.IO).launch {
try {
courseListUp = TCoRAPIController.getCourseListNEW()
} catch (ex: Exception) {
Log.e(className, "could not load course list from tcor", ex)
}
// update coursesList array list
coursesList = courseListUp.courses
// save cache file and update time
save(file, Gson().toJson(courseListUp))
coursesCacheTime = System.currentTimeMillis() / 1000
Preferences.save(context)
}
}
/**
* update the mensa menu, async
*/
fun updateMensaMenu(context: Context): Job {
val file = File(context.filesDir, "mensa.json")
return CoroutineScope(Dispatchers.IO).launch {
try {
mensaMenu = TCoRAPIController.getMensaMenu()
} catch (ex: Exception) {
Log.e(className, "could not load mensa menu from tcor", ex)
}
// save cache file and update time
save(file, Gson().toJson(mensaMenu))
mensaCacheTime = System.currentTimeMillis() / 1000
Preferences.save(context)
}
}
/**
* update the timetable for a week, async
* @param courseName the course name (e.g AI1)
* @param week the week to update (0 for the current and so on)
*/
fun updateTimetable(courseName: String, week: Int, context: Context): Job {
val file = File(context.filesDir, "timetable-$courseName-$week.json")
var timetable = TimetableWeek()
// try to update timetable from tcor, async
return CoroutineScope(Dispatchers.IO).launch {
try {
timetable = TCoRAPIController.getTimetable(courseName, week)
} catch (ex: Exception) {
Log.e(className, "could not load timetable $courseName[$week] from tcor", ex)
}
// update timetable in TTC
if (TimetableController.timetable.size > week) {
TimetableController.timetable[week] = timetable
} else {
TimetableController.timetable.add(timetable)
}
// save cache file and update time
save(file, Gson().toJson(timetable))
timetableCacheTime = System.currentTimeMillis() / 1000
Preferences.save(context)
}
}
/**
* update all additional subject lessons, async
*/
fun updateAdditionalLessons(context: Context): Job {
val fileLessons = File(context.filesDir, "additional_lessons.json")
return CoroutineScope(Dispatchers.IO).launch {
TimetableController.subjectMap.forEach { (courseName, subjects) ->
// update all subjects for a course
subjects.forEach {subject ->
try {
TCoRAPIController.getLessons(courseName, subject, 0).forEach { lesson ->
TimetableController.addLesson(courseName, subject, lesson)
}
} catch (ex: Exception) {
Log.e(className, "could not load $courseName: $subject", ex)
}
}
}
save(fileLessons, Gson().toJson(TimetableController.lessonMap))
}
}
/**
* 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,
* called on addSubject or removeSubject
*/
fun saveAdditionalSubjects(context: Context) {
val fileLessons = File(context.filesDir, "additional_lessons.json")
val fileSubjects = File(context.filesDir, "additional_subjects.json")
save(fileLessons, Gson().toJson(TimetableController.lessonMap))
save(fileSubjects, Gson().toJson(TimetableController.subjectMap))
}
private fun save(file: File, text: String) {
try {
val writer = BufferedWriter(FileWriter(file))
writer.write(text)
writer.close()
} catch (ex: Exception) {
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)
}
}
}
}
/**
* read coursesList, mensa (current and next week), timetable (current and next week)
* @param courseName the course name (e.g AI1)
*/
private fun readStartCache(courseName: String) {
try {
readCoursesList()
} catch (ex : Exception) {
Log.e(className, "Error while reading the course list", ex)
}
try {
readMensa()
} catch (ex : Exception) {
Log.e(className, "Error while reading the mensa menu", ex)
}
try {
readTimetable(courseName, 0)
} catch (ex : Exception) {
Log.e(className, "Error while reading timetable week 0", ex)
}
try {
readTimetable(courseName, 1)
} catch (ex : Exception) {
Log.e(className, "Error while reading timetable week 1", ex)
}
try {
readAdditionalSubjects()
} catch (ex : Exception) {
Log.e(className, "Error while reading additional subjects", ex)
}
}
/**
* read the courses list from the cached file
* add them to the coursesList object
*/
private fun readCoursesList() {
val file = File(context.filesDir, "courses.json")
// make sure the file exists
if (!file.exists()) {
runBlocking { updateCourseList(context).join() }
return
}
coursesList = FileReader(file).use {
GsonBuilder().create().fromJson(it, CoursesList().javaClass).courses
}
}
/**
* get the MensaMenu object from the cached json,
* if cache is empty create the cache file
*/
private fun readMensa() {
val file = File(context.filesDir, "mensa.json")
// make sure the file exists
if (!file.exists()) {
runBlocking { updateMensaMenu(context).join() }
return
}
mensaMenu = FileReader(file).use {
GsonBuilder().create().fromJson(it, MensaMenu().javaClass)
}
}
/**
* read the weeks timetable from the cached file
* @param courseName the course name (e.g AI1)
* @param week the week to read (0 for the current and so on)
*/
private fun readTimetable(courseName: String, week: Int) {
val file = File(context.filesDir, "timetable-$courseName-$week.json")
// if the file does not exist, call updateTimetable blocking and return
if (!file.exists()) {
runBlocking { updateTimetable(courseName, week, context).join() }
return
}
val timetableObject = FileReader(file).use {
JsonParser.parseString(BufferedReader(it).readLine()).asJsonObject
}
// if its a TimetableCourseWeek object migrate to TimetableWeek TODO remove converting at version 0.8
val timetable = if(timetableObject.has("meta")) {
Log.i(Companion.className, "trying to migrate TimetableCourseWeek to TimetableWeek")
val timetableWC = Gson().fromJson(timetableObject, TimetableCourseWeek().javaClass)
save(file, Gson().toJson(TimetableWeek(
timetableWC.meta.weekIndex,
timetableWC.meta.weekNumberYear,
timetableWC.timetable.days
)))
TimetableWeek(timetableWC.meta.weekIndex, timetableWC.meta.weekNumberYear, timetableWC.timetable.days)
} else {
Gson().fromJson(timetableObject, TimetableWeek().javaClass)
}
// update timetable in TTC
if (TimetableController.timetable.size > week) {
TimetableController.timetable[week] = timetable
} else {
TimetableController.timetable.add(timetable)
}
}
private fun readAdditionalSubjects() {
val fileLessons = File(context.filesDir, "additional_lessons.json")
val fileSubjects = File(context.filesDir, "additional_subjects.json")
// make sure the file exists
if (!fileLessons.exists() || !fileSubjects.exists()) {
return
}
// clear the maps before loading, just to be save
TimetableController.lessonMap.clear()
TimetableController.subjectMap.clear()
// read subjects and lessons from cache file
FileReader(fileLessons).use {
TimetableController.lessonMap.putAll(
GsonBuilder().create()
.fromJson(it, object : TypeToken<HashMap<String, Lesson>>() {}.type)
)
}
FileReader(fileSubjects).use {
TimetableController.subjectMap.putAll(
GsonBuilder().create()
.fromJson(it, HashMap<String, ArrayList<String>>().javaClass)
)
}
// add lessons to timetable
TimetableController.lessonMap.forEach { (_, lesson) ->
TimetableController.addLessonToTimetable(lesson)
}
}
/**
* 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()
}
}