Browse Source

save additional subjects and load them on start

* the app now saves all aditional subjects and lessons and loads them on start
* udpate gradle 6.4.0 -> 6.5.1
* update coroutines 1.3.7 -> 1.3.8
* update several androidx libraries
pull/46/head
Jannik 6 months ago
parent
commit
fb3dab6dc3
Signed by: Seil0 GPG Key ID: E8459F3723C52C24
13 changed files with 174 additions and 85 deletions
  1. +5
    -5
      app/build.gradle
  2. +1
    -1
      app/src/main/java/org/mosad/seil0/projectlaogai/MainActivity.kt
  3. +93
    -21
      app/src/main/java/org/mosad/seil0/projectlaogai/controller/cache/CacheController.kt
  4. +48
    -17
      app/src/main/java/org/mosad/seil0/projectlaogai/controller/cache/TimetableController.kt
  5. +2
    -2
      app/src/main/java/org/mosad/seil0/projectlaogai/fragments/HomeFragment.kt
  6. +2
    -3
      app/src/main/java/org/mosad/seil0/projectlaogai/fragments/MensaFragment.kt
  7. +4
    -4
      app/src/main/java/org/mosad/seil0/projectlaogai/fragments/SettingsFragment.kt
  8. +11
    -23
      app/src/main/java/org/mosad/seil0/projectlaogai/fragments/TimeTableFragment.kt
  9. +4
    -8
      app/src/main/java/org/mosad/seil0/projectlaogai/uicomponents/AddSubjectDialog.kt
  10. BIN
      gradle/wrapper/gradle-wrapper.jar
  11. +1
    -1
      gradle/wrapper/gradle-wrapper.properties
  12. +2
    -0
      gradlew
  13. +1
    -0
      gradlew.bat

+ 5
- 5
app/build.gradle View File

@ -47,13 +47,13 @@ android {
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7'
implementation 'androidx.core:core:1.3.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.8'
implementation 'androidx.core:core:1.3.1'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta8'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-rc1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.google.android.material:material:1.1.0'
implementation 'com.google.android.material:material:1.2.0'
implementation 'com.google.code.gson:gson:2.8.6'
implementation 'com.afollestad:aesthetic:1.0.0-beta05'
implementation 'com.afollestad.material-dialogs:core:3.3.0'


+ 1
- 1
app/src/main/java/org/mosad/seil0/projectlaogai/MainActivity.kt View File

@ -43,7 +43,7 @@ import com.afollestad.aesthetic.NavigationViewMode
import com.google.android.material.navigation.NavigationView
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.android.synthetic.main.app_bar_main.*
import org.mosad.seil0.projectlaogai.controller.CacheController
import org.mosad.seil0.projectlaogai.controller.cache.CacheController
import org.mosad.seil0.projectlaogai.controller.NFCMensaCard
import org.mosad.seil0.projectlaogai.controller.PreferencesController
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cColorAccent


app/src/main/java/org/mosad/seil0/projectlaogai/controller/CacheController.kt → app/src/main/java/org/mosad/seil0/projectlaogai/controller/cache/CacheController.kt View File

@ -20,33 +20,30 @@
*
*/
package org.mosad.seil0.projectlaogai.controller
package org.mosad.seil0.projectlaogai.controller.cache
import android.content.Context
import android.util.Log
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.PreferencesController
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cCourse
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.coursesCacheTime
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.mensaCacheTime
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.timetableCacheTime
import org.mosad.seil0.projectlaogai.controller.TCoRAPIController
import org.mosad.seil0.projectlaogai.util.*
import java.io.*
import java.util.*
import kotlin.Exception
import kotlin.collections.ArrayList
/**
* TODO rework
* * move all write functions from TCorController to CacheController
* * init: check for blocking updates, read all data from cache, check for non blocking update
* * [x] save needed data to validate cache in separate file
* * new functions:
* * [x] updateTimetable(): get Timetable object from TCoRController, write object to file
* * [x] updateMensa(): get MensaMenu object from TCoRController, write object to file
* * [ ] save additional subject and lessons
* The cacheController reads and updates the cache files.
* It contains the courseList and mensaMenu object, all timetable objects
* are located in TimetableController.
*/
class CacheController(cont: Context) {
@ -67,6 +64,7 @@ class CacheController(cont: Context) {
GlobalScope.launch(Dispatchers.Default) { 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
@ -94,8 +92,7 @@ class CacheController(cont: Context) {
updateMensaMenu(context)
if (currentTime - timetableCacheTime > 10800) {
updateTimetable(cCourse.courseName, 0, context)
updateTimetable(cCourse.courseName, 1, context)
TimetableController.update(context)
}
}
@ -163,7 +160,7 @@ class CacheController(cont: Context) {
try {
timetable = TCoRAPIController.getTimetable(courseName, week)
} catch (ex: Exception) {
Log.e(className, "could not load timetable from tcor", ex)
Log.e(className, "could not load timetable $courseName[$week] from tcor", ex)
}
// update timetable in TTC
@ -180,6 +177,42 @@ class CacheController(cont: Context) {
}
}
/**
* update all additional subject lessons, async
*/
fun updateAdditionalLessons(context: Context): Job {
val fileLessons = File(context.filesDir, "additional_lessons.json")
return GlobalScope.launch(Dispatchers.IO) {
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))
}
}
/**
* 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))
@ -188,7 +221,6 @@ class CacheController(cont: Context) {
} catch (ex: Exception) {
Log.e(className, "failed to write file \"${file.absoluteFile}\"", ex)
}
}
}
@ -199,36 +231,41 @@ class CacheController(cont: Context) {
*/
private fun readStartCache(courseName: String) {
try {
readCoursesList(context)
readCoursesList()
} catch (ex : Exception) {
Log.e(className, "Error while reading the course list", ex)
}
try {
readMensa(context)
readMensa()
} catch (ex : Exception) {
Log.e(className, "Error while reading the mensa menu", ex)
}
try {
readTimetable(courseName, 0, context)
readTimetable(courseName, 0)
} catch (ex : Exception) {
Log.e(className, "Error while reading timetable week 0", ex)
}
try {
readTimetable(courseName, 1, context)
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(context: Context) {
private fun readCoursesList() {
val file = File(context.filesDir, "courses.json")
// make sure the file exists
@ -246,7 +283,7 @@ class CacheController(cont: Context) {
* get the MensaMenu object from the cached json,
* if cache is empty create the cache file
*/
private fun readMensa(context: Context) {
private fun readMensa() {
val file = File(context.filesDir, "mensa.json")
// make sure the file exists
@ -265,7 +302,7 @@ class CacheController(cont: Context) {
* @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, context: Context) {
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
@ -300,4 +337,39 @@ class CacheController(cont: Context) {
}
}
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(BufferedReader(it).readLine(), object : TypeToken<HashMap<String, Lesson>>() {}.type)
)
}
FileReader(fileSubjects).use {
TimetableController.subjectMap.putAll(
GsonBuilder().create()
.fromJson(BufferedReader(it).readLine(), HashMap<String, ArrayList<String>>().javaClass)
)
}
// add lessons to timetable
TimetableController.lessonMap.forEach { (_, lesson) ->
TimetableController.addLessonToTimetable(lesson)
}
}
}

app/src/main/java/org/mosad/seil0/projectlaogai/controller/TimetableController.kt → app/src/main/java/org/mosad/seil0/projectlaogai/controller/cache/TimetableController.kt View File

@ -20,18 +20,21 @@
*
*/
package org.mosad.seil0.projectlaogai.controller
package org.mosad.seil0.projectlaogai.controller.cache
import android.content.Context
import kotlinx.coroutines.Job
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cCourse
import org.mosad.seil0.projectlaogai.controller.TCoRAPIController
import org.mosad.seil0.projectlaogai.util.Lesson
import org.mosad.seil0.projectlaogai.util.TimetableWeek
/**
* TODO this controller contains
* * a list with all additional lessons
* * the main course and additional info for timetables
* * all concrete objects
* The TimetableController contains the timetable, subjectMap
* and lessonMap objects. It also contains the additional subjects logic.
* All functions ro read or update cache files are located in the CacheController.
*
* TODO add configurable week to addSubject() and removeSubject()
* TODO add configurable week to addSubject() and removeSubject(), updateAdditionalLessons()
*/
class TimetableController {
@ -40,13 +43,24 @@ class TimetableController {
val lessonMap = HashMap<String, Lesson>() // the key is courseName-subject-lessonID
val subjectMap = HashMap<String, ArrayList<String>>() // the key is courseName
/**
* update the main timetable and all additional subjects, async
*/
fun update(context: Context): List<Job> {
return listOf(
CacheController.updateTimetable(cCourse.courseName, 0, context),
CacheController.updateTimetable(cCourse.courseName, 1, context),
CacheController.updateAdditionalLessons(context)
)
}
/**
* add a subject to the subjectMap and all it's lessons
* to the lessonMap
* @param courseName course to which the subject belongs
* @param subject the subjects name
*/
fun addSubject(courseName: String, subject: String) {
fun addSubject(courseName: String, subject: String, context: Context) {
// add subject
if (subjectMap.containsKey(courseName)) {
subjectMap[courseName]?.add(subject)
@ -55,16 +69,11 @@ class TimetableController {
}
// add concrete lessons
TCoRAPIController.getLessons(courseName, subject, 0).forEach {lesson ->
//the courseName, subject and lessonID, separator: -
val key = "$courseName-$subject-${lesson.lessonID}"
lessonMap[key] = lesson
// add lesson to the timetable
val id = lesson.lessonID.split(".")
if(id.size == 3)
timetable[0].days[id[0].toInt()].timeslots[id[1].toInt()].add(lesson)
TCoRAPIController.getLessons(courseName, subject, 0).forEach { lesson ->
addLesson(courseName, subject, lesson)
}
CacheController.saveAdditionalSubjects(context)
}
/**
@ -73,7 +82,7 @@ class TimetableController {
* @param courseName course to which the subject belongs
* @param subject the subjects name
*/
fun removeSubject(courseName: String, subject: String) {
fun removeSubject(courseName: String, subject: String, context: Context) {
// remove subject
subjectMap[courseName]?.remove(subject)
@ -91,6 +100,28 @@ class TimetableController {
timetable[0].days[id[0].toInt()].timeslots[id[1].toInt()].remove(it.value)
}
}
CacheController.saveAdditionalSubjects(context)
}
/**
* add a lesson to the lessonMap, also add it to the timetable
*/
fun addLesson(courseName: String, subject: String, lesson: Lesson) {
//the courseName, subject and lessonID, separator: -
val key = "$courseName-$subject-${lesson.lessonID}"
lessonMap[key] = lesson
addLessonToTimetable(lesson)
}
/**
* add a lesson to the timetable
*/
fun addLessonToTimetable(lesson: Lesson) {
val id = lesson.lessonID.split(".")
if(id.size == 3)
timetable[0].days[id[0].toInt()].timeslots[id[1].toInt()].add(lesson)
}
}

+ 2
- 2
app/src/main/java/org/mosad/seil0/projectlaogai/fragments/HomeFragment.kt View File

@ -33,8 +33,8 @@ import androidx.fragment.app.Fragment
import kotlinx.android.synthetic.main.fragment_home.*
import kotlinx.coroutines.*
import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.CacheController.Companion.mensaMenu
import org.mosad.seil0.projectlaogai.controller.TimetableController
import org.mosad.seil0.projectlaogai.controller.cache.CacheController.Companion.mensaMenu
import org.mosad.seil0.projectlaogai.controller.cache.TimetableController
import org.mosad.seil0.projectlaogai.util.Meal
import org.mosad.seil0.projectlaogai.util.TimetableDay
import org.mosad.seil0.projectlaogai.uicomponents.DayCardView


+ 2
- 3
app/src/main/java/org/mosad/seil0/projectlaogai/fragments/MensaFragment.kt View File

@ -33,10 +33,9 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.CacheController
import org.mosad.seil0.projectlaogai.controller.CacheController.Companion.mensaMenu
import org.mosad.seil0.projectlaogai.controller.cache.CacheController
import org.mosad.seil0.projectlaogai.controller.cache.CacheController.Companion.mensaMenu
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cShowBuffet
import org.mosad.seil0.projectlaogai.controller.TCoRAPIController
import org.mosad.seil0.projectlaogai.util.MensaWeek
import org.mosad.seil0.projectlaogai.uicomponents.DayCardView
import org.mosad.seil0.projectlaogai.uicomponents.MealLinearLayout


+ 4
- 4
app/src/main/java/org/mosad/seil0/projectlaogai/fragments/SettingsFragment.kt View File

@ -47,14 +47,14 @@ import kotlinx.android.synthetic.main.fragment_settings.*
import kotlinx.coroutines.*
import org.mosad.seil0.projectlaogai.BuildConfig
import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.CacheController
import org.mosad.seil0.projectlaogai.controller.CacheController.Companion.coursesList
import org.mosad.seil0.projectlaogai.controller.cache.CacheController
import org.mosad.seil0.projectlaogai.controller.cache.CacheController.Companion.coursesList
import org.mosad.seil0.projectlaogai.controller.PreferencesController
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cColorAccent
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cColorPrimary
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cCourse
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cShowBuffet
import org.mosad.seil0.projectlaogai.controller.TimetableController
import org.mosad.seil0.projectlaogai.controller.cache.TimetableController
import org.mosad.seil0.projectlaogai.util.DataTypes
import java.util.*
@ -168,7 +168,7 @@ class SettingsFragment : Fragment() {
listItemsMultiChoice(items = lessons) { _, _, items ->
items.forEach {
val list = it.split(" - ")
TimetableController.removeSubject(list[0], list[1])
TimetableController.removeSubject(list[0], list[1], context)
// TODO save
}
}


+ 11
- 23
app/src/main/java/org/mosad/seil0/projectlaogai/fragments/TimeTableFragment.kt View File

@ -35,11 +35,9 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.android.synthetic.main.fragment_timetable.*
import kotlinx.coroutines.*
import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.CacheController
import org.mosad.seil0.projectlaogai.controller.PreferencesController
import org.mosad.seil0.projectlaogai.controller.PreferencesController.Companion.cCourse
import org.mosad.seil0.projectlaogai.controller.TimetableController
import org.mosad.seil0.projectlaogai.controller.TimetableController.Companion.timetable
import org.mosad.seil0.projectlaogai.controller.cache.TimetableController
import org.mosad.seil0.projectlaogai.controller.cache.TimetableController.Companion.timetable
import org.mosad.seil0.projectlaogai.uicomponents.AddSubjectDialog
import org.mosad.seil0.projectlaogai.uicomponents.DayCardView
import org.mosad.seil0.projectlaogai.util.NotRetardedCalendar
@ -82,7 +80,8 @@ class TimeTableFragment : Fragment() {
private fun initActions() = GlobalScope.launch(Dispatchers.Main) {
refreshLayout_Timetable.setOnRefreshListener {
updateTimetableScreen()
runBlocking { TimetableController.update(context!!).joinAll() }
reloadTimetableUI()
}
// show the AddLessonDialog if the ftaBtn is clicked
@ -115,16 +114,14 @@ class TimeTableFragment : Fragment() {
}
private fun addTimetableWeek(dayBegin: Int, dayEnd: Int, week: Int) = GlobalScope.launch(Dispatchers.Main) {
val timetable = timetable[week]
for (dayIndex in dayBegin..dayEnd) {
val dayCardView = DayCardView(context!!)
// some wired calendar magic, calculate the correct date to be shown
// ((timetable week - current week * 7) + (dayIndex - dayIndex of current week)
val daysToAdd = ((TimetableController.timetable[week].weekNumberYear - NotRetardedCalendar.getWeekOfYear())
val daysToAdd = ((timetable[week].weekNumberYear - NotRetardedCalendar.getWeekOfYear())
* 7 + (dayIndex - NotRetardedCalendar.getDayOfWeekIndex()))
dayCardView.addTimetableDay(timetable.days[dayIndex], daysToAdd)
dayCardView.addTimetableDay(timetable[week].days[dayIndex], daysToAdd)
// if there are no lessons don't show the dayCardView
if (dayCardView.getLinLayoutDay().childCount > 1)
@ -133,21 +130,12 @@ class TimeTableFragment : Fragment() {
}
}
private fun updateTimetableScreen() = GlobalScope.launch(Dispatchers.Default) {
// update the cache
val threads = listOf(
CacheController.updateTimetable(cCourse.courseName, 0, context!!),
CacheController.updateTimetable(cCourse.courseName, 1, context!!)
)
threads.joinAll() // blocking since we want the new data
refreshUI()
}
// TODO rename
fun refreshUI() = GlobalScope.launch(Dispatchers.Default) {
/**
* clear linLayout_Timetable, add the updated timetable
*/
fun reloadTimetableUI() = GlobalScope.launch(Dispatchers.Default) {
withContext(Dispatchers.Main) {
// remove all menus from the layout
// remove all lessons from the layout
linLayout_Timetable.removeAllViews()
// add the refreshed timetables


+ 4
- 8
app/src/main/java/org/mosad/seil0/projectlaogai/uicomponents/AddSubjectDialog.kt View File

@ -35,9 +35,9 @@ import com.afollestad.materialdialogs.customview.customView
import com.afollestad.materialdialogs.customview.getCustomView
import kotlinx.coroutines.runBlocking
import org.mosad.seil0.projectlaogai.R
import org.mosad.seil0.projectlaogai.controller.CacheController
import org.mosad.seil0.projectlaogai.controller.cache.CacheController
import org.mosad.seil0.projectlaogai.controller.TCoRAPIController
import org.mosad.seil0.projectlaogai.controller.TimetableController
import org.mosad.seil0.projectlaogai.controller.cache.TimetableController
import org.mosad.seil0.projectlaogai.fragments.TimeTableFragment
import org.mosad.seil0.projectlaogai.util.Course
import java.util.stream.Collectors
@ -71,12 +71,8 @@ class AddSubjectDialog(_context: Context) {
println("add lesson \"$selectedCourse: $selectedSubject\"")
println(lessons.toString())
TimetableController.addSubject(selectedCourse, selectedSubject)
// TODO refresh timetable (add a function to show additional lessons)
runBlocking {
ttf.refreshUI()
}
// TODO save
TimetableController.addSubject(selectedCourse, selectedSubject,context)
runBlocking { ttf.reloadTimetableUI() }
}
.negativeButton(R.string.cancel)


BIN
gradle/wrapper/gradle-wrapper.jar View File


+ 1
- 1
gradle/wrapper/gradle-wrapper.properties View File

@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.4-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

+ 2
- 0
gradlew View File

@ -82,6 +82,7 @@ esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
@ -129,6 +130,7 @@ fi
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath


+ 1
- 0
gradlew.bat View File

@ -84,6 +84,7 @@ set CMD_LINE_ARGS=%*
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%


Loading…
Cancel
Save