/** * ProjectLaogai * * Copyright 2019-2020 * * 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() 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>() {}.type) ) } FileReader(fileSubjects).use { TimetableController.subjectMap.putAll( GsonBuilder().create() .fromJson(it, HashMap>().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> { 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> = encryptedFile.openFileInput().bufferedReader().use { GsonBuilder().create() .fromJson(it, object : TypeToken>>() {}.type) } // same sorting as qispos parser return map.toSortedMap(compareBy{ val oText = it.substringAfter(" ") if (oText.contains("/")) { oText.substringBefore("/").toInt() + 0.5 } else { oText.toDouble() } }.thenBy { it }) } return sortedMapOf() } }