initial commit
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/*
|
||||
/.idea - PC/*
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
app/release/*
|
||||
19
FUPlanner 2.iml
Normal file
19
FUPlanner 2.iml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module external.linked.project.id="FUPlanner 2" external.linked.project.path="$MODULE_DIR$" external.root.project.path="$MODULE_DIR$" external.system.id="GRADLE" type="JAVA_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="java-gradle" name="Java-Gradle">
|
||||
<configuration>
|
||||
<option name="BUILD_FOLDER_PATH" value="$MODULE_DIR$/build" />
|
||||
<option name="BUILDABLE" value="false" />
|
||||
</configuration>
|
||||
</facet>
|
||||
</component>
|
||||
<component name="NewModuleRootManager" LANGUAGE_LEVEL="JDK_1_8" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.gradle" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
251
app/app.iml
Normal file
251
app/app.iml
Normal file
File diff suppressed because one or more lines are too long
79
app/build.gradle
Normal file
79
app/build.gradle
Normal file
@@ -0,0 +1,79 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'androidx.navigation.safeargs.kotlin'
|
||||
|
||||
android {
|
||||
compileSdkVersion 29
|
||||
buildToolsVersion "29.0.3"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "de.sebse.fuplanner2"
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 29
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug {
|
||||
//minifyEnabled true
|
||||
//shrinkResources true
|
||||
resValue("string", "PORT_NUMBER", "8081")
|
||||
}
|
||||
}
|
||||
|
||||
dataBinding {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
// To inline the bytecode built with JVM target 1.8 into
|
||||
// bytecode that is being built with JVM target 1.6. (e.g. navArgs)
|
||||
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation 'androidx.appcompat:appcompat:1.1.0'
|
||||
implementation 'androidx.core:core-ktx:1.2.0'
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
implementation 'com.google.android.material:material:1.1.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.2.1'
|
||||
implementation 'androidx.navigation:navigation-ui-ktx:2.2.1'
|
||||
implementation 'androidx.fragment:fragment:1.2.4'
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.0.0"
|
||||
implementation 'com.android.volley:volley:1.1.1'
|
||||
implementation 'androidx.room:room-runtime:2.2.5'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
|
||||
kapt 'androidx.room:room-compiler:2.2.5'
|
||||
implementation 'androidx.room:room-ktx:2.2.5'
|
||||
implementation 'com.beust:klaxon:5.0.1'
|
||||
implementation 'androidx.work:work-runtime-ktx:2.3.4'
|
||||
implementation 'androidx.paging:paging-runtime:2.1.2'
|
||||
implementation 'com.github.thellmund.android-week-view:core:4.1.5'
|
||||
testImplementation 'junit:junit:4.12'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
||||
|
||||
debugImplementation 'com.amitshekhar.android:debug-db:1.0.6'
|
||||
}
|
||||
27
app/demo.kts
Normal file
27
app/demo.kts
Normal file
@@ -0,0 +1,27 @@
|
||||
import java.lang.StringBuilder
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
// https://lms.fu-berlin.de/learn/api/v1/courses?fields=id,name,description,courseId,startDate,endDate
|
||||
// paging.nextPage
|
||||
|
||||
suspend fun sendGet(uri: String): String {
|
||||
val url = URL(uri)
|
||||
url
|
||||
val str = StringBuilder()
|
||||
with(url.openConnection() as HttpURLConnection) {
|
||||
requestMethod = "GET" // optional default is GET
|
||||
addRequestProperty("Cookie", "JSESSIONID=1C7D8F7562968A6693BDD5C5F049D35A; session_id=8286C021DFB6CCA0DB4089A1D31EB422; s_session_id=C2CB57E4AEEFBF6496A09F517CDBDE91; web_client_cache_guid=f6ec07e0-7c39-4d0e-b690-5abab8d6cc06; loginType=shibboleth; JSESSIONID=3929500B4C87181844342524E78BDC89; _shibsession_64656661756c7468747470733a2f2f6c6d732e66752d6265726c696e2e64652f73686962626f6c657468=_a144d509c3e7defaec6a5a7d23d5f00c")
|
||||
println("\nSent 'GET' request to URL : $url; Response Code : $responseCode")
|
||||
inputStream.bufferedReader().use {
|
||||
it.lines().forEach { line ->
|
||||
str.append(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str.toString()
|
||||
}
|
||||
|
||||
launch {
|
||||
val x = sendGet("https://lms.fu-berlin.de/learn/api/v1/courses?fields=id,name,description,courseId,startDate,endDate")
|
||||
println(x)
|
||||
}
|
||||
28
app/demo.py
Normal file
28
app/demo.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import urllib.request
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
|
||||
url = 'https://lms.fu-berlin.de/learn/api/v1/courses?fields=id,name,description,courseId,startDate,endDate'
|
||||
|
||||
# now, with the below headers, we defined ourselves as a simpleton who is
|
||||
# still using internet explorer.
|
||||
headers = {}
|
||||
headers['Cookie'] = "JSESSIONID=1C7D8F7562968A6693BDD5C5F049D35A; session_id=8286C021DFB6CCA0DB4089A1D31EB422; s_session_id=C2CB57E4AEEFBF6496A09F517CDBDE91; web_client_cache_guid=f6ec07e0-7c39-4d0e-b690-5abab8d6cc06; loginType=shibboleth; JSESSIONID=3929500B4C87181844342524E78BDC89; _shibsession_64656661756c7468747470733a2f2f6c6d732e66752d6265726c696e2e64652f73686962626f6c657468=_a144d509c3e7defaec6a5a7d23d5f00c"
|
||||
|
||||
|
||||
with open('your_file.txt', 'w') as f:
|
||||
f.write("description°courseId°name°id°startDate°endDate")
|
||||
while url is not None:
|
||||
req = urllib.request.Request(url, headers = headers)
|
||||
resp = urllib.request.urlopen(req)
|
||||
respData = resp.read().decode('utf-8')
|
||||
j = json.loads(respData)
|
||||
if len(j['results']) == 0:
|
||||
url = None
|
||||
else:
|
||||
url = 'https://lms.fu-berlin.de' + urllib.parse.unquote_plus(j['paging']['nextPage'])
|
||||
for result in j['results']:
|
||||
data = result.get('description', "") + "°" + result.get('courseId', "") + "°" + result.get('name', "") + "°" + result.get('id', "") + "°" + result.get('startDate', "") + "°" + result.get('endDate', "") + "\n"
|
||||
f.write(data)
|
||||
print(url)
|
||||
27
app/demo.ws.kts
Normal file
27
app/demo.ws.kts
Normal file
@@ -0,0 +1,27 @@
|
||||
import java.lang.StringBuilder
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
// https://lms.fu-berlin.de/learn/api/v1/courses?fields=id,name,description,courseId,startDate,endDate
|
||||
// paging.nextPage
|
||||
|
||||
fun sendGet(uri: String): String {
|
||||
val url = URL(uri)
|
||||
url
|
||||
val str = StringBuilder()
|
||||
with(url.openConnection() as HttpURLConnection) {
|
||||
requestMethod = "GET" // optional default is GET
|
||||
addRequestProperty("Cookie", "JSESSIONID=1C7D8F7562968A6693BDD5C5F049D35A; session_id=8286C021DFB6CCA0DB4089A1D31EB422; s_session_id=C2CB57E4AEEFBF6496A09F517CDBDE91; web_client_cache_guid=f6ec07e0-7c39-4d0e-b690-5abab8d6cc06; loginType=shibboleth; JSESSIONID=3929500B4C87181844342524E78BDC89; _shibsession_64656661756c7468747470733a2f2f6c6d732e66752d6265726c696e2e64652f73686962626f6c657468=_a144d509c3e7defaec6a5a7d23d5f00c")
|
||||
println("\nSent 'GET' request to URL : $url; Response Code : $responseCode")
|
||||
inputStream.bufferedReader().use {
|
||||
it.lines().forEach { line ->
|
||||
str.append(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
return str.toString()
|
||||
}
|
||||
|
||||
val x = sendGet("https://lms.fu-berlin.de/learn/api/v1/courses?fields=id,name,description,courseId,startDate,endDate")
|
||||
println(x)
|
||||
val parsed = JSONObject(x)
|
||||
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.sebse.fuplanner2
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("de.sebse.fuplanner2", appContext.packageName)
|
||||
}
|
||||
}
|
||||
42
app/src/main/AndroidManifest.xml
Normal file
42
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="de.sebse.fuplanner2">
|
||||
|
||||
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
|
||||
<uses-permission android:name="android.permission.MANAGE_ACCOUNTS" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/FUTheme"
|
||||
android:name=".CustomApplication">
|
||||
<activity android:name=".StartupActivity"
|
||||
android:theme="@style/FUTheme.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:theme="@style/FUTheme.NoActionBarDark">
|
||||
</activity>
|
||||
<activity android:name=".auth.FuplannerAccountActivity"
|
||||
android:theme="@style/FUTheme" />
|
||||
<service android:name=".auth.FuplannerAccountService"
|
||||
android:permission="de.sebse.fuauth">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/fuplanner_authenticator" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
15
app/src/main/java/de/sebse/fuplanner2/CustomApplication.kt
Normal file
15
app/src/main/java/de/sebse/fuplanner2/CustomApplication.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package de.sebse.fuplanner2
|
||||
|
||||
import android.app.Application
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.preferences.AppPreferences
|
||||
|
||||
class CustomApplication: Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AppDatabase.initialize(applicationContext)
|
||||
AppPreferences.initialize(applicationContext)
|
||||
AppAccounts.initialize(applicationContext)
|
||||
}
|
||||
}
|
||||
146
app/src/main/java/de/sebse/fuplanner2/MainActivity.kt
Normal file
146
app/src/main/java/de/sebse/fuplanner2/MainActivity.kt
Normal file
@@ -0,0 +1,146 @@
|
||||
package de.sebse.fuplanner2
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.*
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.ui.AppBarConfiguration
|
||||
import androidx.navigation.ui.navigateUp
|
||||
import androidx.navigation.ui.setupActionBarWithNavController
|
||||
import androidx.navigation.ui.setupWithNavController
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import kotlinx.android.synthetic.main.activity_main.*
|
||||
import kotlinx.android.synthetic.main.app_bar_main.*
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class MainActivity() : AppCompatActivity() {
|
||||
|
||||
private lateinit var appBarConfiguration: AppBarConfiguration
|
||||
private lateinit var activityViewModel: MainActivityViewModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
setSupportActionBar(toolbar)
|
||||
|
||||
val navController = findNavController(R.id.nav_host_fragment)
|
||||
activityViewModel = ViewModelProvider(this).get(MainActivityViewModel::class.java)
|
||||
|
||||
activityViewModel.user.observe(this) {
|
||||
nav_view.getHeaderView(0).run {
|
||||
findViewById<TextView>(R.id.nav_header_title).text = getString(R.string.full_name, it?.firstName, it?.lastName)
|
||||
findViewById<TextView>(R.id.nav_header_subtitle).text = it?.email
|
||||
}
|
||||
}
|
||||
activityViewModel.notificationCnt.observe(this) {
|
||||
nav_view.menu.findItem(R.id.nav_notifications).apply {
|
||||
icon = ContextCompat.getDrawable(
|
||||
this@MainActivity,
|
||||
if (it == 0) R.drawable.ic_menu_notifications_none else R.drawable.ic_menu_notifications
|
||||
)
|
||||
actionView = if (it == 0)
|
||||
null
|
||||
else layoutInflater.inflate(R.layout.nav_action_view_counter, nav_view, false).apply {
|
||||
findViewById<TextView>(R.id.counterText).text = when (it) {
|
||||
in 0..99 -> it.toString()
|
||||
else -> "99+"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
activityViewModel.latestSemester.observe(this) {
|
||||
val courseOrder = nav_view.menu.findItem(R.id.nav_courses).order
|
||||
var i = nav_view.menu.size() - 1
|
||||
while(i >= 0) {
|
||||
val menuItem: MenuItem = nav_view.menu.getItem(i--)
|
||||
if (menuItem.order / 100 == courseOrder / 100 && menuItem.order != courseOrder) {
|
||||
nav_view.menu.removeItem(menuItem.itemId)
|
||||
}
|
||||
}
|
||||
it.mapIndexed { index, course ->
|
||||
val itemOrder = courseOrder / 100 * 100 + index + 2
|
||||
nav_view.menu.add(0, itemOrder, itemOrder, course.title).setOnMenuItemClickListener {
|
||||
navController.navigate(
|
||||
R.id.course_details,
|
||||
bundleOf("courseId" to course.uid, "title" to course.title),
|
||||
NavOptions.Builder().setPopUpTo(R.id.nav_courses, false).build()
|
||||
)
|
||||
drawer_layout.closeDrawers()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appBarConfiguration = AppBarConfiguration(
|
||||
setOf(R.id.nav_courses, R.id.nav_canteen, R.id.nav_schedule, R.id.nav_notifications),
|
||||
drawer_layout
|
||||
)
|
||||
setupActionBarWithNavController(navController, appBarConfiguration)
|
||||
nav_view.setupWithNavController(navController)
|
||||
|
||||
if (intent.getBooleanExtra(EXTRA_OPEN_NOTIFICATIONS, false)) {
|
||||
navController.navigate(R.id.nav_notifications)
|
||||
intent.putExtra(EXTRA_OPEN_NOTIFICATIONS, false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val accounts = AppAccounts.getInstance()
|
||||
val selectedAccount = accounts.selectedAccount
|
||||
if (selectedAccount == null) {
|
||||
val intent = Intent(this, StartupActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME
|
||||
startActivity(intent)
|
||||
finish()
|
||||
return
|
||||
}
|
||||
activityViewModel.updateSelectedUser(selectedAccount)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
// Inflate the menu; this adds items to the action bar if it is present.
|
||||
//menuInflater.inflate(R.menu.main, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
val navController = findNavController(R.id.nav_host_fragment)
|
||||
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_OPEN_NOTIFICATIONS: String = "EXTRA_OPEN_NOTIFICATIONS"
|
||||
const val EXTRA_NETWORK_ERROR = "EXTRA_NETWORK_ERROR"
|
||||
const val EXTRA_UNSPECIFIED_ERROR = "EXTRA_UNSPECIFIED_ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
class MainActivityViewModel : ViewModel() {
|
||||
val database = AppDatabase.getInstance()
|
||||
val user: MutableLiveData<User> = MutableLiveData<User>().apply {
|
||||
value = null
|
||||
}
|
||||
val notificationCnt: LiveData<Int> = database.notificationDao().getUnreadRowCount()
|
||||
val latestSemester: LiveData<List<Course>> = database.courseDao().getLatestSemester()
|
||||
|
||||
fun updateSelectedUser(account: Account) {
|
||||
GlobalScope.launch {
|
||||
user.postValue(database.userDao().findByUsername(account.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
71
app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt
Normal file
71
app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt
Normal file
@@ -0,0 +1,71 @@
|
||||
package de.sebse.fuplanner2
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.auth.FuplannerAccountActivity
|
||||
import de.sebse.fuplanner2.auth.FuplannerAccountConstants
|
||||
import de.sebse.fuplanner2.utils.Notifications
|
||||
|
||||
|
||||
class StartupActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_startup)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
|
||||
// has accounts? -> false: goto AccountActivity (add=true) / exit for result
|
||||
// get selected account -> false: select other
|
||||
// refresh token -> false: goto AccountActivity (add=false, pre-fill username) / exit
|
||||
// goto MainActivity
|
||||
|
||||
|
||||
Notifications.init(applicationContext)
|
||||
val accounts = AppAccounts.getInstance()
|
||||
val selectedAccount = accounts.selectedAccount
|
||||
if (selectedAccount == null) {
|
||||
val intent = FuplannerAccountActivity.createIntent(
|
||||
this,
|
||||
true,
|
||||
FuplannerAccountConstants.FU_ACC_TYPE
|
||||
)
|
||||
startActivityForResult(intent, LAUNCH_LOGIN_RESULT)
|
||||
} else {
|
||||
accounts.refresh(selectedAccount) {
|
||||
when (it) {
|
||||
AppAccounts.RefreshResults.UNSPECIFIED_ERROR,
|
||||
AppAccounts.RefreshResults.NETWORK_ERROR,
|
||||
AppAccounts.RefreshResults.SUCCESS -> {
|
||||
AppAccounts.getInstance().setPeriodicSync()
|
||||
val intent = Intent(this, MainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_TASK_ON_HOME
|
||||
if (it == AppAccounts.RefreshResults.NETWORK_ERROR)
|
||||
intent.putExtra(MainActivity.EXTRA_NETWORK_ERROR, true)
|
||||
if (it == AppAccounts.RefreshResults.UNSPECIFIED_ERROR)
|
||||
intent.putExtra(MainActivity.EXTRA_UNSPECIFIED_ERROR, true)
|
||||
startActivity(intent)
|
||||
}
|
||||
AppAccounts.RefreshResults.INVALID_PASSWORD -> {
|
||||
val intent = FuplannerAccountActivity.createIntent(
|
||||
this,
|
||||
true,
|
||||
accountType = selectedAccount.type,
|
||||
accountName = selectedAccount.name
|
||||
)
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val LAUNCH_LOGIN_RESULT = 111
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
|
||||
@SuppressLint("Registered")
|
||||
open class AccountAuthenticatorAppCompatActivity : AppCompatActivity() {
|
||||
private var mAccountAuthenticatorResponse: AccountAuthenticatorResponse? = null
|
||||
private var mResultBundle: Bundle? = null
|
||||
fun setAccountAuthenticatorResult(result: Bundle?) {
|
||||
mResultBundle = result
|
||||
}
|
||||
|
||||
override fun onCreate(icicle: Bundle?) {
|
||||
super.onCreate(icicle)
|
||||
mAccountAuthenticatorResponse =
|
||||
intent.getParcelableExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)
|
||||
if (mAccountAuthenticatorResponse != null) {
|
||||
mAccountAuthenticatorResponse!!.onRequestContinued()
|
||||
}
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
if (mAccountAuthenticatorResponse != null) {
|
||||
// send the result bundle back if set, otherwise send an error.
|
||||
if (mResultBundle != null) {
|
||||
mAccountAuthenticatorResponse!!.onResult(mResultBundle)
|
||||
} else {
|
||||
mAccountAuthenticatorResponse!!.onError(
|
||||
AccountManager.ERROR_CODE_CANCELED,
|
||||
"canceled"
|
||||
)
|
||||
}
|
||||
mAccountAuthenticatorResponse = null
|
||||
}
|
||||
super.finish()
|
||||
}
|
||||
}
|
||||
148
app/src/main/java/de/sebse/fuplanner2/auth/AppAccounts.kt
Normal file
148
app/src/main/java/de/sebse/fuplanner2/auth/AppAccounts.kt
Normal file
@@ -0,0 +1,148 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.AuthenticatorException
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.work.*
|
||||
import de.sebse.fuplanner2.preferences.AppPreferences
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.worker.SyncWorker
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class AppAccounts(val context: Context) {
|
||||
private val accountManager = AccountManager.get(context)
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
|
||||
val list: Array<out Account>
|
||||
get() = accountManager.getAccountsByType(FuplannerAccountConstants.FU_ACC_TYPE)
|
||||
|
||||
var selectedAccount: Account?
|
||||
get() {
|
||||
val preferences = AppPreferences.getInstance()
|
||||
val accountName = preferences.getString(PREF_SELECTED_ACCOUNT)
|
||||
return getAccount(accountName)
|
||||
?.also { preferences.set(PREF_SELECTED_ACCOUNT, it.name ) }
|
||||
}
|
||||
set(value) {
|
||||
val preferences = AppPreferences.getInstance()
|
||||
value
|
||||
?.run { preferences.set(PREF_SELECTED_ACCOUNT, name ) }
|
||||
?: preferences.remove(PREF_SELECTED_ACCOUNT)
|
||||
}
|
||||
|
||||
fun hasAccounts(): Boolean {
|
||||
return list.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasAccountName(name: String, accountType: String? = null): Boolean {
|
||||
return accountManager.getAccountsByType(accountType ?: FuplannerAccountConstants.FU_ACC_TYPE).find { it.name == name } != null
|
||||
}
|
||||
|
||||
suspend fun refreshSuspended(account: Account): RefreshResults = suspendCoroutine { res -> refresh(account) { res.resume(it) } }
|
||||
|
||||
fun refresh(account: Account, cb: (RefreshResults) -> Unit) {
|
||||
val authToken: String? = accountManager.peekAuthToken(account, FuplannerAccountConstants.FU_ACC_TOKEN_TYPE)
|
||||
accountManager.invalidateAuthToken(FuplannerAccountConstants.FU_ACC_TYPE, authToken)
|
||||
accountManager.getAuthToken(
|
||||
account,
|
||||
FuplannerAccountConstants.FU_ACC_TOKEN_TYPE,
|
||||
null,true, {
|
||||
try {
|
||||
it.result
|
||||
cb(RefreshResults.SUCCESS)
|
||||
} catch (e: AuthenticatorException) {
|
||||
if (e.message == FuplannerAccountConstants.MSG_INVALID_CREDENTIALS) {
|
||||
cb(RefreshResults.INVALID_PASSWORD)
|
||||
} else {
|
||||
console.error(e)
|
||||
cb(RefreshResults.UNSPECIFIED_ERROR)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
console.error(e)
|
||||
cb(RefreshResults.NETWORK_ERROR)
|
||||
} catch (e: Throwable) {
|
||||
console.error(e)
|
||||
cb(RefreshResults.UNSPECIFIED_ERROR)
|
||||
}
|
||||
}, null)
|
||||
}
|
||||
|
||||
fun setPassword(account: Account, passWd: String?) = accountManager.setPassword(account, passWd)
|
||||
fun addAccountExplicitly(account: Account, password: String?, bundle: Bundle?) {
|
||||
accountManager.addAccountExplicitly(account, password, bundle)
|
||||
}
|
||||
|
||||
fun setAuthToken(account: Account, tokenType: String?, authToken: String?) {
|
||||
accountManager.setAuthToken(account, tokenType, authToken)
|
||||
}
|
||||
|
||||
fun getAccount(accountName: String?): Account? {
|
||||
val account = list.find { it.name == accountName }
|
||||
return account?.let { return it }
|
||||
?: list.getOrNull(0)
|
||||
}
|
||||
|
||||
fun setPeriodicSync() {
|
||||
workManager.cancelAllWorkByTag(TAG_SYNC)
|
||||
val preferences = AppPreferences.getInstance()
|
||||
val syncPeriod = preferences.getLong(PREF_SYNC_PERIOD_MINUTES) ?: 15L
|
||||
if (syncPeriod != null) {
|
||||
list.forEach {
|
||||
val syncWork = PeriodicWorkRequestBuilder<SyncWorker>(syncPeriod, TimeUnit.MINUTES)
|
||||
.setInputData(workDataOf(SyncWorker.KEY_ACCOUNT_NAME to it.name))
|
||||
.setConstraints(Constraints.Builder()
|
||||
//.setRequiresDeviceIdle(true)
|
||||
.setRequiresStorageNotLow(true)
|
||||
//.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
)
|
||||
.addTag(TAG_SYNC)
|
||||
.addTag(it.name)
|
||||
.build()
|
||||
workManager.enqueue(syncWork)
|
||||
}
|
||||
} else {
|
||||
console.warn("No sync period set! Removed all accounts from syncing!")
|
||||
}
|
||||
}
|
||||
|
||||
enum class RefreshResults {
|
||||
SUCCESS,
|
||||
INVALID_PASSWORD,
|
||||
NETWORK_ERROR,
|
||||
UNSPECIFIED_ERROR
|
||||
}
|
||||
|
||||
|
||||
companion object {
|
||||
const val PREF_SELECTED_ACCOUNT = "PREF_SELECTED_ACCOUNT"
|
||||
const val PREF_SYNC_PERIOD_MINUTES = "PREF_SYNC_PERIOD_MINUTES"
|
||||
const val TAG_SYNC = "TAG_SYNC"
|
||||
|
||||
private var sInstance: AppAccounts? = null
|
||||
|
||||
fun initialize(context: Context): AppAccounts? {
|
||||
if (sInstance == null) {
|
||||
synchronized(AppAccounts::class.java) {
|
||||
if (sInstance == null) {
|
||||
sInstance = AppAccounts(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sInstance
|
||||
}
|
||||
|
||||
fun getInstance(): AppAccounts {
|
||||
sInstance?.let {
|
||||
return it
|
||||
}
|
||||
throw NullPointerException("Please call initialize() before getting the instance.")
|
||||
}
|
||||
}
|
||||
}
|
||||
84
app/src/main/java/de/sebse/fuplanner2/auth/FUAuthModule.kt
Normal file
84
app/src/main/java/de/sebse/fuplanner2/auth/FUAuthModule.kt
Normal file
@@ -0,0 +1,84 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.Header
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.network.NetData
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.network.tools.invalidPassword
|
||||
import de.sebse.fuplanner2.network.tools.invalidResponse
|
||||
import de.sebse.fuplanner2.utils.xml
|
||||
import java.net.URI
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
|
||||
abstract class FUAuthModule {
|
||||
companion object {
|
||||
// 7-10 days (random to reduce heap load)
|
||||
val REFRESH_FREQUENCY_COURSES_MILLIS = TimeUnit.HOURS.toMillis(7*24) + Random.nextLong(TimeUnit.HOURS.toMillis(3*24))
|
||||
}
|
||||
|
||||
abstract suspend fun isAvailable(ctx: Context, name: String): Boolean
|
||||
abstract suspend fun login(ctx: Context, name: String, password: String, user: User)
|
||||
|
||||
suspend fun doSaml(ctx: Context, samlUrl: String, name: String, password: String, user: User): SamlReponse {
|
||||
val requester = Requester(ctx)
|
||||
var response = requester.get(samlUrl, getCookies(user))
|
||||
updateCookies(user, response)
|
||||
if (response.networkResponse.statusCode == 200) {
|
||||
return parseResponse(response.body)
|
||||
} else {
|
||||
val relLocation = response.headers["Location"]
|
||||
?: throw invalidResponse(100110, "No IDP form location!")
|
||||
val formUri = URI(samlUrl).resolve(relLocation).toString()
|
||||
requester.head(formUri, getCookies(user))
|
||||
response = requester.post(
|
||||
formUri,
|
||||
cookies = getCookies(user),
|
||||
data = hashMapOf("j_username" to name, "j_password" to password, "_eventId_proceed" to "")
|
||||
)
|
||||
if (response.networkResponse.statusCode != 200) {
|
||||
throw invalidPassword(100111, "Password or username invalid!")
|
||||
}
|
||||
}
|
||||
return parseResponse(response.body)
|
||||
}
|
||||
|
||||
protected fun parseCookies(headers: List<Header>): HashMap<String, String> {
|
||||
val result: HashMap<String, String> = hashMapOf()
|
||||
headers
|
||||
.filter { it.name == "Set-Cookie" }
|
||||
.forEach {
|
||||
result[it.value.substringBefore("=")] = it.value.substringAfter("=").substringBefore(";")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseResponse(body: String): SamlReponse {
|
||||
var matcher = "name=\"SAMLResponse\" value=\"(.*?)\"".toRegex().find(body)
|
||||
val samlResponse = matcher?.groupValues?.let {
|
||||
if (it.size >= 2) it[1] else null
|
||||
} ?: throw invalidResponse(100100, "No SAML response found!")
|
||||
matcher = "name=\"RelayState\" value=\"(.*?)\"".toRegex().find(body)
|
||||
val relayState = matcher?.groupValues?.let {
|
||||
if (it.size >= 2) it[1] else null
|
||||
} ?: throw invalidResponse(100100, "No Relay State found!")
|
||||
matcher = "form action=\"(.*?)\"".toRegex().find(body)
|
||||
val url = matcher?.groupValues?.let {
|
||||
if (it.size >= 2) it[1] else null
|
||||
} ?: throw invalidResponse(100100, "No SAML Url found!")
|
||||
return SamlReponse(xml.decode(url), xml.decode(relayState), xml.decode(samlResponse))
|
||||
}
|
||||
|
||||
private fun updateCookies(user: User, response: NetData) {
|
||||
val setCookies = parseCookies(response.networkResponse.allHeaders)
|
||||
setCookies["JSESSIONID"]?.let {
|
||||
user.cookies.idpJsessionId = it
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCookies(user: User): HashMap<String, String>? {
|
||||
return user.cookies.idpJsessionId?.let { key -> hashMapOf("JSESSIONID" to key) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountAuthenticatorResponse
|
||||
import android.accounts.AccountManager
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.TextView
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
|
||||
class FuplannerAccountActivity : AccountAuthenticatorAppCompatActivity() {
|
||||
private val REQ_REGISTER = 11
|
||||
override fun onCreate(icicle: Bundle?) {
|
||||
super.onCreate(icicle)
|
||||
setContentView(R.layout.activity_login)
|
||||
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun login(view: View?) {
|
||||
val userId =
|
||||
(findViewById<View>(R.id.user) as EditText).text.toString()
|
||||
val passWd =
|
||||
(findViewById<View>(R.id.password) as EditText).text.toString()
|
||||
findViewById<View>(R.id.login).isEnabled = false
|
||||
|
||||
val accountType = intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE)
|
||||
|
||||
val result = Intent()
|
||||
GlobalScope.async {
|
||||
// Check if account is already added
|
||||
val accounts = AppAccounts.getInstance()
|
||||
if (accounts.hasAccountName(userId, accountType)) {
|
||||
throw IllegalArgumentException("Account already bound!")
|
||||
}
|
||||
|
||||
val authToken = FuplannerAccountHelper.authenticate(applicationContext, userId, passWd, addNewUser = intent.getBooleanExtra(FuplannerAccountConstants.KEY_ADD_ACCOUNT, false))
|
||||
val tokenType: String = FuplannerAccountHelper.getTokenType(userId)
|
||||
val data = Bundle()
|
||||
data.putString(AccountManager.KEY_ACCOUNT_NAME, userId)
|
||||
data.putString(AccountManager.KEY_ACCOUNT_TYPE, accountType)
|
||||
data.putString(FuplannerAccountConstants.KEY_AUTH_TOKEN_TYPE, tokenType)
|
||||
data.putString(AccountManager.KEY_AUTHTOKEN, authToken)
|
||||
data.putString(FuplannerAccountConstants.KEY_PASSWORD, passWd)
|
||||
result.putExtras(data)
|
||||
}.invokeOnCompletion {
|
||||
if (it !== null) {
|
||||
console.error("An error occured!", it)
|
||||
runOnUiThread {
|
||||
findViewById<EditText>(R.id.user).text.clear()
|
||||
findViewById<EditText>(R.id.user).setText("seedorf96", TextView.BufferType.EDITABLE)
|
||||
findViewById<EditText>(R.id.password).text.clear()
|
||||
findViewById<EditText>(R.id.password).setText("m&gcwBaT@", TextView.BufferType.EDITABLE)
|
||||
findViewById<View>(R.id.login).isEnabled = true
|
||||
}
|
||||
} else if (!this.isFinishing) {
|
||||
this.setLoginResult(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLoginResult(intent: Intent) {
|
||||
val userId = intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)
|
||||
val passWd = intent.getStringExtra(FuplannerAccountConstants.KEY_PASSWORD)
|
||||
val account = Account(userId, intent.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE))
|
||||
val accounts = AppAccounts.getInstance()
|
||||
if (getIntent().getBooleanExtra(FuplannerAccountConstants.KEY_ADD_ACCOUNT, false)) {
|
||||
val authtoken = intent.getStringExtra(AccountManager.KEY_AUTHTOKEN)
|
||||
val tokenType =
|
||||
intent.getStringExtra(FuplannerAccountConstants.KEY_AUTH_TOKEN_TYPE)
|
||||
accounts.addAccountExplicitly(account, passWd, null)
|
||||
accounts.setAuthToken(account, tokenType, authtoken)
|
||||
} else {
|
||||
accounts.setPassword(account, passWd)
|
||||
}
|
||||
setAccountAuthenticatorResult(intent.extras)
|
||||
setResult(Activity.RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
if (data != null && resultCode == Activity.RESULT_OK && requestCode == REQ_REGISTER) {
|
||||
setLoginResult(data)
|
||||
} else super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createIntent(activityCtx: Context, addAccount: Boolean,
|
||||
accountType: String? = null,
|
||||
accountName: String? = null,
|
||||
response: AccountAuthenticatorResponse? = null,
|
||||
authTokenType: String? = null
|
||||
): Intent {
|
||||
return Intent(activityCtx, FuplannerAccountActivity::class.java).run {
|
||||
putExtra(FuplannerAccountConstants.KEY_ADD_ACCOUNT, addAccount)
|
||||
if (response != null)
|
||||
putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
|
||||
if (accountName != null)
|
||||
putExtra(AccountManager.KEY_ACCOUNT_NAME, accountName)
|
||||
putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType ?: FuplannerAccountConstants.FU_ACC_TYPE)
|
||||
putExtra(FuplannerAccountConstants.KEY_AUTH_TOKEN_TYPE, authTokenType ?: FuplannerAccountConstants.FU_ACC_TOKEN_TYPE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
import android.accounts.*
|
||||
import android.accounts.AccountManager.KEY_BOOLEAN_RESULT
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import com.android.volley.VolleyError
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.async
|
||||
|
||||
|
||||
class FuplannerAccountAuthenticator(ctx: Context) : AbstractAccountAuthenticator(ctx) {
|
||||
private val context: Context = ctx.applicationContext
|
||||
override fun editProperties(
|
||||
accountAuthenticatorResponse: AccountAuthenticatorResponse,
|
||||
s: String
|
||||
): Bundle? {
|
||||
return null
|
||||
}
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
accountType: String?,
|
||||
authTokenType: String?,
|
||||
requiredFeatures: Array<String>?,
|
||||
options: Bundle?
|
||||
): Bundle {
|
||||
return Bundle().apply {
|
||||
val intent = FuplannerAccountActivity.createIntent(
|
||||
context,
|
||||
true,
|
||||
accountType,
|
||||
response = response,
|
||||
authTokenType = authTokenType
|
||||
)
|
||||
putParcelable(AccountManager.KEY_INTENT, intent)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun confirmCredentials(
|
||||
accountAuthenticatorResponse: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
bundle: Bundle
|
||||
): Bundle? {
|
||||
return null
|
||||
}
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
authTokenType: String,
|
||||
options: Bundle
|
||||
): Bundle? {
|
||||
val accountManager = AccountManager.get(context)
|
||||
var authToken = accountManager.peekAuthToken(account, authTokenType)
|
||||
if (TextUtils.isEmpty(authToken)) {
|
||||
val password = accountManager.getPassword(account)
|
||||
if (password != null) {
|
||||
val result = Bundle()
|
||||
GlobalScope.async {
|
||||
authToken = FuplannerAccountHelper.authenticate(
|
||||
context,
|
||||
account.name,
|
||||
password,
|
||||
false
|
||||
)
|
||||
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
|
||||
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
|
||||
result.putString(AccountManager.KEY_AUTHTOKEN, authToken)
|
||||
}.invokeOnCompletion {
|
||||
if (it == null) {
|
||||
response.onResult(result)
|
||||
return@invokeOnCompletion
|
||||
}
|
||||
var message = it.message
|
||||
val code = if (it is VolleyError) {
|
||||
when (it.networkResponse.statusCode) {
|
||||
403 -> { // Auth failed / wrong credentials
|
||||
message = FuplannerAccountConstants.MSG_INVALID_CREDENTIALS
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
AccountManager.ERROR_CODE_BAD_AUTHENTICATION
|
||||
else
|
||||
AccountManager.ERROR_CODE_BAD_ARGUMENTS
|
||||
}
|
||||
422 // Processing failed; MAYBE auth failed / wrong credentials
|
||||
-> AccountManager.ERROR_CODE_CANCELED
|
||||
418 // Timeout
|
||||
-> AccountManager.ERROR_CODE_NETWORK_ERROR
|
||||
else // Other network error
|
||||
-> AccountManager.ERROR_CODE_NETWORK_ERROR
|
||||
}
|
||||
} else { // Other undefined error
|
||||
AccountManager.ERROR_CODE_CANCELED
|
||||
}
|
||||
response.onError(code, message)
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
return Bundle().apply {
|
||||
val intent = FuplannerAccountActivity.createIntent(
|
||||
context,
|
||||
true,
|
||||
account.type,
|
||||
accountName = account.name,
|
||||
response = response,
|
||||
authTokenType = authTokenType
|
||||
)
|
||||
putParcelable(AccountManager.KEY_INTENT, intent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Token found and returned
|
||||
val result = Bundle()
|
||||
result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
|
||||
result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type)
|
||||
result.putString(AccountManager.KEY_AUTHTOKEN, authToken)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
override fun getAuthTokenLabel(s: String): String {
|
||||
return "full"
|
||||
}
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun updateCredentials(
|
||||
accountAuthenticatorResponse: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
s: String,
|
||||
bundle: Bundle
|
||||
): Bundle? {
|
||||
return null
|
||||
}
|
||||
|
||||
@Throws(NetworkErrorException::class)
|
||||
override fun hasFeatures(
|
||||
accountAuthenticatorResponse: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
strings: Array<String>
|
||||
): Bundle {
|
||||
val result = Bundle()
|
||||
result.putBoolean(KEY_BOOLEAN_RESULT, false)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
object FuplannerAccountConstants {
|
||||
const val MSG_INVALID_CREDENTIALS = "Invalid credentials provided!"
|
||||
const val KEY_ADD_ACCOUNT = "key_add_account"
|
||||
const val KEY_AUTH_TOKEN_TYPE = "key_auth_token_type"
|
||||
const val KEY_PASSWORD = "key_password"
|
||||
const val FU_ACC_TOKEN_TYPE = "fuauth"
|
||||
const val FU_ACC_TYPE = "de.sebse.fuauth"
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
import android.content.Context
|
||||
import de.sebse.fuplanner2.blackboard.Blackboard
|
||||
import de.sebse.fuplanner2.blackboard.BlackboardInfo
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.whiteboard.Whiteboard
|
||||
import de.sebse.fuplanner2.whiteboard.WhiteboardInfo
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object FuplannerAccountHelper {
|
||||
suspend fun authenticate(
|
||||
ctx: Context,
|
||||
name: String,
|
||||
password: String,
|
||||
addNewUser: Boolean
|
||||
): String {
|
||||
val database = AppDatabase.getInstance()
|
||||
if (addNewUser) {
|
||||
console.warn("Previous user account will be removed!")
|
||||
removeAccount(name)
|
||||
}
|
||||
val user = database.userDao().findByUsername(name) ?: User(
|
||||
cookies = UserCookies(),
|
||||
bbInfo = BlackboardInfo(),
|
||||
wbInfo = WhiteboardInfo(),
|
||||
userName = name
|
||||
)
|
||||
|
||||
var available = Blackboard.isAvailable(ctx, name)
|
||||
if (available)
|
||||
Blackboard.login(ctx, name, password, user)
|
||||
|
||||
available = Whiteboard.isAvailable(ctx, name)
|
||||
if (available) {
|
||||
Whiteboard.login(ctx, name, password, user)
|
||||
}
|
||||
|
||||
database.userDao().upsert(user)
|
||||
|
||||
return user.toString()
|
||||
}
|
||||
|
||||
fun getTokenType(userId: String): String {
|
||||
return FuplannerAccountConstants.FU_ACC_TOKEN_TYPE
|
||||
}
|
||||
|
||||
suspend fun removeAccount(userName: String) {
|
||||
val database = AppDatabase.getInstance()
|
||||
withContext(Dispatchers.Default) {
|
||||
database.userDao().deleteByUserName(userName)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
|
||||
import android.os.IBinder
|
||||
|
||||
|
||||
class FuplannerAccountService: Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
val authenticator = FuplannerAccountAuthenticator(this)
|
||||
return authenticator.getIBinder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
data class SamlReponse(val uri: String, val relayState: String, val samlResponse: String)
|
||||
56
app/src/main/java/de/sebse/fuplanner2/auth/UserCookies.kt
Normal file
56
app/src/main/java/de/sebse/fuplanner2/auth/UserCookies.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package de.sebse.fuplanner2.auth
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
class UserCookies {
|
||||
var idpJsessionId: String? = null
|
||||
var wbJsessionId: String? = null
|
||||
var wbShibKey: String? = null
|
||||
var wbShibValue: String? = null
|
||||
var bbJsessionId: String? = null
|
||||
var bbSessionId: String? = null
|
||||
var bbSSessionId: String? = null
|
||||
var bbShibKey: String? = null
|
||||
var bbShibValue: String? = null
|
||||
|
||||
constructor() { }
|
||||
|
||||
constructor(json: String) {
|
||||
val obj = JSONObject(json)
|
||||
idpJsessionId = obj.opt("idpJsessionId") as String?
|
||||
wbJsessionId = obj.opt("wbJsessionId") as String?
|
||||
wbShibKey = obj.opt("wbShibKey") as String?
|
||||
wbShibValue = obj.opt("wbShibValue") as String?
|
||||
bbJsessionId = obj.opt("bbJsessionId") as String?
|
||||
bbSessionId = obj.opt("bbSessionId") as String?
|
||||
bbSSessionId = obj.opt("bbSSessionId") as String?
|
||||
bbShibKey = obj.opt("bbShibKey") as String?
|
||||
bbShibValue = obj.opt("bbShibValue") as String?
|
||||
}
|
||||
|
||||
fun update(user: UserCookies) {
|
||||
idpJsessionId = user.idpJsessionId?: idpJsessionId
|
||||
wbJsessionId = user.wbJsessionId?: wbJsessionId
|
||||
wbShibKey = user.wbShibKey?: wbShibKey
|
||||
wbShibValue = user.wbShibValue?: wbShibValue
|
||||
bbJsessionId = user.bbJsessionId?: bbJsessionId
|
||||
bbSessionId = user.bbSessionId?: bbSessionId
|
||||
bbSSessionId = user.bbSSessionId?: bbSSessionId
|
||||
bbShibKey = user.bbShibKey?: bbShibKey
|
||||
bbShibValue = user.bbShibValue?: bbShibValue
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("idpJsessionId", idpJsessionId)
|
||||
obj.put("wbJsessionId", wbJsessionId)
|
||||
obj.put("wbShibKey", wbShibKey)
|
||||
obj.put("wbShibValue", wbShibValue)
|
||||
obj.put("bbJsessionId", bbJsessionId)
|
||||
obj.put("bbSessionId", bbSessionId)
|
||||
obj.put("bbSSessionId", bbSSessionId)
|
||||
obj.put("bbShibKey", bbShibKey)
|
||||
obj.put("bbShibValue", bbShibValue)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
145
app/src/main/java/de/sebse/fuplanner2/blackboard/Blackboard.kt
Normal file
145
app/src/main/java/de/sebse/fuplanner2/blackboard/Blackboard.kt
Normal file
@@ -0,0 +1,145 @@
|
||||
package de.sebse.fuplanner2.blackboard
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.beust.klaxon.JsonArray
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
import de.sebse.fuplanner2.auth.FUAuthModule
|
||||
import de.sebse.fuplanner2.auth.SamlReponse
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.network.NetData
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.network.tools.invalidResponse
|
||||
import java.net.URI
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
||||
object Blackboard: FUAuthModule() {
|
||||
private const val LOGIN_URL = "https://lms.fu-berlin.de/lms-apps/login/sso/index.php"
|
||||
private const val TEST_URL = "https://lms.fu-berlin.de/learn/api/public/v1/users?userName=%s&fields=id,userName,studentId,name,contact"
|
||||
private const val RESTORE_SESSION_URI = "https://lms.fu-berlin.de"
|
||||
internal const val MODULE_TYPE = 1
|
||||
|
||||
internal const val FETCH_COURSE_MEMBERSHIP = "https://lms.fu-berlin.de/learn/api/public/v1/users/%s/courses?limit=200&fields=courseId" // userId
|
||||
internal const val FETCH_COURSE_DETAILS = "https://lms.fu-berlin.de/learn/api/v1/courses/%s?fields=displayName,description,courseId,startDate,endDate" // courseId
|
||||
internal const val FETCH_COURSE_USERS = "https://lms.fu-berlin.de/learn/api/public/v1/courses/%s/users?fields=courseRoleId,userId&limit=200" // courseId
|
||||
internal const val FETCH_USER_DETAILS = "https://lms.fu-berlin.de/learn/api/public/v1/users/%s?fields=name,userName" // lecturerId
|
||||
|
||||
internal const val FETCH_VV_COURSE_ID = "https://www.fu-berlin.de/vv/de/search?utf8=✓&query=%s"
|
||||
internal const val FETCH_VV_COURSE = "https://www.fu-berlin.de/vv/de/lv/%s"
|
||||
// 14-21 days (random to reduce heap load)
|
||||
val REFRESH_FREQUENCY_EVENT_CACHE_MILLIS = TimeUnit.HOURS.toMillis(14*24) +
|
||||
Random.nextLong(TimeUnit.HOURS.toMillis(21*24))
|
||||
|
||||
|
||||
internal const val FETCH_ANNOUNCEMENT_LIST = "https://lms.fu-berlin.de/learn/api/v1/courses/%s/announcements?fields=id,title,body.rawText,startDateRestriction,creatorUserId" // courseId
|
||||
|
||||
override suspend fun isAvailable(ctx: Context, name: String): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun login(ctx: Context, name: String, password: String, user: User) {
|
||||
val requester = Requester(ctx)
|
||||
var response = requester.head(LOGIN_URL, cookies = getCookies(user, shib = true))
|
||||
val samlUri = response.headers["Location"]
|
||||
?: throw invalidResponse(101100, "Location header not set!")
|
||||
|
||||
if (!samlUri.startsWith(RESTORE_SESSION_URI)) {
|
||||
val samlResponse: SamlReponse = doSaml(ctx, samlUri, name, password, user)
|
||||
|
||||
// Shib-Session-Cookie
|
||||
response = requester.post(samlResponse.uri, cookies = null, data = hashMapOf(
|
||||
"RelayState" to samlResponse.relayState,
|
||||
"SAMLResponse" to samlResponse.samlResponse
|
||||
))
|
||||
updateCookies(user, response)
|
||||
// Finish BB
|
||||
response = requester.get(
|
||||
response.networkResponse.headers["Location"] ?: throw invalidResponse(101101, "No Location header to finish Blackboard"),
|
||||
getCookies(user, shib = true)
|
||||
)
|
||||
}
|
||||
// Start Session
|
||||
response = requester.get(
|
||||
response.networkResponse.headers["Location"] ?: throw invalidResponse(101102, "No Location header to start Blackboard session"),
|
||||
getCookies(user, shib = true)
|
||||
)
|
||||
|
||||
updateCookies(user, response)
|
||||
// Set API JSESSION
|
||||
response = requester.get(String.format(TEST_URL, Uri.encode(name)), getCookies(user, shib = true))
|
||||
updateCookies(user, response)
|
||||
user.bbInfo.id = (Parser.default().parse(StringBuilder(response.body)) as JsonObject)
|
||||
.array<JsonObject>("result")
|
||||
?.find { entry -> entry["userName"] == name }
|
||||
?.string("id")
|
||||
|
||||
(Parser.default().parse(StringBuilder(response.body)) as JsonObject)
|
||||
.array<JsonObject>("results")
|
||||
?.find { entry -> entry["userName"] == name }
|
||||
?.run {
|
||||
user.bbInfo.id = string("id") ?: user.wbInfo.id
|
||||
user.email = obj("contact")?.string("email") ?: user.email
|
||||
user.matNumber = string("studentId")?.toIntOrNull() ?: user.matNumber
|
||||
user.firstName = obj("name")?.string("given") ?: user.firstName
|
||||
user.lastName = obj("name")?.string("family") ?: user.lastName
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCookies(user: User, response: NetData) {
|
||||
val setCookies = parseCookies(response.networkResponse.allHeaders)
|
||||
setCookies["JSESSIONID"]?.let {
|
||||
user.cookies.bbJsessionId = it
|
||||
}
|
||||
setCookies["session_id"]?.let {
|
||||
user.cookies.bbSessionId = it
|
||||
}
|
||||
setCookies["s_session_id"]?.let {
|
||||
user.cookies.bbSSessionId = it
|
||||
}
|
||||
setCookies
|
||||
.filter{ (key, _) -> key.startsWith("_shibsession_") }
|
||||
.forEach { (key, value) ->
|
||||
user.cookies.bbShibKey = key
|
||||
user.cookies.bbShibValue = value
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getCookies(user: User, shib: Boolean = true): HashMap<String, String>? {
|
||||
val cookies = user.cookies.bbJsessionId?.let { key -> hashMapOf("JSESSIONID" to key) } ?: hashMapOf()
|
||||
user.cookies.bbSessionId?.let { key -> cookies["session_id"] = key }
|
||||
user.cookies.bbSSessionId?.let { key -> cookies["s_session_id"] = key }
|
||||
if (shib && user.cookies.bbShibValue != null) {
|
||||
user.cookies.bbShibKey?.let { cookies[it] = user.cookies.bbShibValue ?: "" }
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
internal fun isAvailable(user: User): Boolean {
|
||||
return user.cookies.bbJsessionId != null && user.cookies.bbSessionId != null && user.cookies.bbSSessionId != null
|
||||
}
|
||||
|
||||
internal suspend fun requestList(
|
||||
requester: Requester, user: User,
|
||||
entryUri: String,
|
||||
arrayPath: ((JsonObject) -> JsonArray<JsonObject>?),
|
||||
nextPage: ((JsonObject) -> String?)
|
||||
): Array<JsonObject> {
|
||||
var data: Array<JsonObject> = arrayOf()
|
||||
var url: String = entryUri
|
||||
do {
|
||||
val json = requester.get(url, getCookies(user)).let {
|
||||
Parser.default().parse(StringBuilder(it.body)) as JsonObject
|
||||
}
|
||||
val next = nextPage(json) ?: break
|
||||
if (next.isEmpty()) break
|
||||
url = URI(url).resolve(next).toString()
|
||||
val results = arrayPath(json)?.toTypedArray() ?: arrayOf()
|
||||
if (results.isEmpty()) break
|
||||
data += results
|
||||
} while (true)
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.sebse.fuplanner2.blackboard
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
class BlackboardInfo {
|
||||
var id: String? = null
|
||||
|
||||
constructor() { }
|
||||
|
||||
constructor(json: String) {
|
||||
val obj = JSONObject(json)
|
||||
id = obj.opt("id") as String?
|
||||
}
|
||||
|
||||
fun update(user: BlackboardInfo) {
|
||||
id = user.id?: id
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("id", id)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.sebse.fuplanner2.blackboard
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.VolleyError
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
import de.sebse.fuplanner2.auth.FUAuthModule.Companion.REFRESH_FREQUENCY_COURSES_MILLIS
|
||||
import de.sebse.fuplanner2.database.*
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.utils.*
|
||||
import de.sebse.fuplanner2.worker.FetchResourceErrorType
|
||||
import de.sebse.fuplanner2.worker.FetchResourceException
|
||||
|
||||
|
||||
suspend fun Blackboard.getAnnouncements(ctx: Context, database: AppDatabase, user: User, course: Course): UpdateResult<Announcement> {
|
||||
if (!isAvailable(user)) return UpdateResult()
|
||||
if (course.moduleType != MODULE_TYPE) return UpdateResult()
|
||||
val courseId = course.uid ?: return UpdateResult()
|
||||
|
||||
val requester = Requester(ctx)
|
||||
val stored = database.announcementDao().getAll2(courseId)
|
||||
|
||||
try {val data = requester.get(
|
||||
FETCH_ANNOUNCEMENT_LIST.format(course.internalId),
|
||||
getCookies(user)
|
||||
)
|
||||
val json = Parser.default().parse(StringBuilder(data.body)) as JsonObject
|
||||
val new = json.array<JsonObject>("results")?.pmap { obj ->
|
||||
val id: String = obj.string("id") ?: return@pmap null
|
||||
val title: String = obj.string("title") ?: ""
|
||||
val body: String = obj.obj("body")?.string("rawText") ?: ""
|
||||
val createdOn: Long = obj.string("startDateRestriction")?.dateStringToLong("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") ?: 0
|
||||
val createdBy: String = obj.string("creatorUserId")?.let { lecturerUserId ->
|
||||
Cache.getCache(database, lecturerUserId, "Blackboard_Users", REFRESH_FREQUENCY_COURSES_MILLIS) {
|
||||
requester.get(
|
||||
FETCH_USER_DETAILS.format(lecturerUserId),
|
||||
getCookies(user)
|
||||
).body
|
||||
}.let { Parser.default().parse(StringBuilder(it)) as JsonObject }
|
||||
.let {
|
||||
val given = it.obj("name")?.string("given")
|
||||
val family = it.obj("name")?.string("family")
|
||||
if (given != null && family != null)
|
||||
"$given $family"
|
||||
else
|
||||
null
|
||||
}
|
||||
} ?: ""
|
||||
val attachments: List<Attachment> = listOf()
|
||||
Announcement(null, courseId, System.currentTimeMillis(), id, title, body, createdOn, createdBy, attachments)
|
||||
}?.filterNotNull() ?: listOf()
|
||||
val result = updateResultOf(stored, new)
|
||||
database.announcementDao().run {
|
||||
delete(result.removed)
|
||||
upsert(result.values)
|
||||
}
|
||||
return result
|
||||
} catch (e: VolleyError) {
|
||||
val statusCode = e.networkResponse.statusCode
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_NETWORK_ERROR, statusCode)
|
||||
}
|
||||
}
|
||||
159
app/src/main/java/de/sebse/fuplanner2/blackboard/ExtCourses.kt
Normal file
159
app/src/main/java/de/sebse/fuplanner2/blackboard/ExtCourses.kt
Normal file
@@ -0,0 +1,159 @@
|
||||
package de.sebse.fuplanner2.blackboard
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.VolleyError
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
import de.sebse.fuplanner2.auth.FUAuthModule.Companion.REFRESH_FREQUENCY_COURSES_MILLIS
|
||||
import de.sebse.fuplanner2.database.*
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.utils.UpdateResult
|
||||
import de.sebse.fuplanner2.utils.pmap
|
||||
import de.sebse.fuplanner2.utils.updateResultOf
|
||||
import de.sebse.fuplanner2.whiteboard.Whiteboard
|
||||
import de.sebse.fuplanner2.worker.FetchResourceErrorType
|
||||
import de.sebse.fuplanner2.worker.FetchResourceException
|
||||
import java.lang.StringBuilder
|
||||
|
||||
suspend fun Blackboard.getCourseByLocationRef(stored: List<Course>, requester: Requester, user: User, blockedLvNumbers: Set<String>, database: AppDatabase, locationRef: String): Course? {
|
||||
val course = stored.find { it.internalId == locationRef }?.apply {
|
||||
if (lastRefreshed > System.currentTimeMillis() - REFRESH_FREQUENCY_COURSES_MILLIS)
|
||||
return this
|
||||
}
|
||||
return getCourseByCourse(requester, user, blockedLvNumbers, database, locationRef, course)
|
||||
}
|
||||
|
||||
suspend fun Blackboard.getCourseByCourse(requester: Requester, user: User, blockedLvNumbers: Set<String>, database: AppDatabase, locationRef: String, course: Course?): Course? {
|
||||
|
||||
val userId = user.uid ?: return null
|
||||
|
||||
val jsonSite = Cache.getCache(database, locationRef, "Blackboard_Courses", REFRESH_FREQUENCY_COURSES_MILLIS) {
|
||||
requester.get(
|
||||
FETCH_COURSE_DETAILS.format(locationRef),
|
||||
getCookies(user)
|
||||
).body
|
||||
}.let { Parser.default().parse(StringBuilder(it)) as JsonObject }
|
||||
|
||||
val uid = course?.uid
|
||||
val moduleType = course?.moduleType ?: MODULE_TYPE
|
||||
val (isSummerSemester, year) = jsonSite.string("courseId")?.let { courseId ->
|
||||
val groups = Regex("([0-9]{2})([WS])\$").find(courseId)?.groupValues
|
||||
val type: String? = groups?.getOrNull(2)
|
||||
val year: String? = groups?.getOrNull(1)
|
||||
val isSS = type == "S"
|
||||
val yearInt = if (type != null && year != null) year.toIntOrNull(10) else null
|
||||
Pair(isSS, yearInt)
|
||||
} ?: Pair(false, null)
|
||||
val (lvNumbers, type) = jsonSite.string("courseId")?.let { courseId ->
|
||||
val groups = Regex("^[A-Z0-9a-z-]+_([A-Z0-9a-z-]*)_([A-Z0-9a-z-]+)_[0-9]{2}[WS]\$").find(courseId)?.groupValues
|
||||
val type: String? = groups?.getOrNull(1)
|
||||
val lv: String? = groups?.getOrNull(2)
|
||||
Pair(if (lv == null) setOf() else setOf(lv), if (type.isNullOrEmpty()) "Sonstiges" else type)
|
||||
} ?: Pair(setOf(), "Sonstiges")
|
||||
if (lvNumbers.intersect(blockedLvNumbers).any()) return null
|
||||
val lecturers = this.requestList(
|
||||
requester, user, FETCH_COURSE_USERS.format(locationRef),
|
||||
{json -> json.array("results")},
|
||||
{json -> json.obj("paging")?.string("nextPage")}
|
||||
).filter { it.string("courseRoleId") == "Instructor" && it.string("userId")?.isNotEmpty() ?: false }
|
||||
.map { lecturer ->
|
||||
val lecturerUserId = lecturer.string("userId") ?: return@map null
|
||||
Cache.getCache(database, lecturerUserId, "Blackboard_Users", REFRESH_FREQUENCY_COURSES_MILLIS) {
|
||||
requester.get(
|
||||
FETCH_USER_DETAILS.format(lecturerUserId),
|
||||
getCookies(user)
|
||||
).body
|
||||
}.let { Parser.default().parse(StringBuilder(it)) as JsonObject }
|
||||
.let { Lecturer(
|
||||
it.obj("name")?.string("given") ?: "",
|
||||
it.obj("name")?.string("family") ?: "",
|
||||
it.string("userName") + "@zedat.fu-berlin.de",
|
||||
true
|
||||
) }
|
||||
}.filterNotNull()
|
||||
return Course(
|
||||
uid,
|
||||
userId,
|
||||
System.currentTimeMillis(),
|
||||
isSummerSemester,
|
||||
year,
|
||||
lvNumbers.toHashSet(),
|
||||
jsonSite.string("displayName") ?: "",
|
||||
type,
|
||||
jsonSite.string("description") ?: "",
|
||||
locationRef,
|
||||
moduleType,
|
||||
lecturers
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun Blackboard.getCourses(ctx: Context, database: AppDatabase, user: User): UpdateResult<Course> {
|
||||
|
||||
if (!isAvailable(user)) return UpdateResult()
|
||||
val userId = user.uid ?: return UpdateResult()
|
||||
|
||||
val requester = Requester(ctx)
|
||||
val stored = database.courseDao().getAllByType(userId, MODULE_TYPE)
|
||||
val blockedLvNumbers = database.courseDao()
|
||||
.getAllByType(userId, Whiteboard.MODULE_TYPE)
|
||||
.map { it.lvNumber.toSet() }
|
||||
.reduce { a, b -> a.union(b) }
|
||||
|
||||
try {
|
||||
val data = this.requestList(
|
||||
requester, user, FETCH_COURSE_MEMBERSHIP.format(user.bbInfo.id),
|
||||
{json -> json.array("results")},
|
||||
{json -> json.obj("paging")?.string("nextPage")}
|
||||
)
|
||||
val new = data.mapNotNull { it.string("courseId") }.pmap {
|
||||
getCourseByLocationRef(stored, requester, user, blockedLvNumbers, database, locationRef = it)
|
||||
}.filterNotNull()
|
||||
|
||||
val result = updateResultOf(stored, new)
|
||||
database.courseDao().run {
|
||||
delete(result.removed)
|
||||
upsert(result.values)
|
||||
}
|
||||
return result
|
||||
} catch (e: VolleyError) {
|
||||
if (e.networkResponse.statusCode == 401)
|
||||
throw FetchResourceException(
|
||||
FetchResourceErrorType.ERR_AUTHORIZATION
|
||||
)
|
||||
throw FetchResourceException(
|
||||
FetchResourceErrorType.ERR_NETWORK_ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Blackboard.getCourse(ctx: Context, database: AppDatabase, user: User, courseId: Long): UpdateResult<Course> {
|
||||
|
||||
if (!isAvailable(user)) return UpdateResult()
|
||||
|
||||
val requester = Requester(ctx)
|
||||
val blockedLvNumbers = database.courseDao()
|
||||
.getAllByType(user.uid!!, Whiteboard.MODULE_TYPE)
|
||||
.map { it.lvNumber.toSet() }
|
||||
.reduce { a, b -> a.union(b) }
|
||||
|
||||
try {
|
||||
database.courseDao().run {
|
||||
val old = getCourseById2(courseId)
|
||||
if (old.moduleType == MODULE_TYPE) {
|
||||
getCourseByCourse(requester, user, blockedLvNumbers, database, old.internalId, old)?.also {
|
||||
upsert(it)
|
||||
return updateResultOf(listOf(old), listOf(it))
|
||||
}
|
||||
}
|
||||
return UpdateResult()
|
||||
}
|
||||
} catch (e: VolleyError) {
|
||||
if (e.networkResponse.statusCode == 401)
|
||||
throw FetchResourceException(
|
||||
FetchResourceErrorType.ERR_AUTHORIZATION
|
||||
)
|
||||
throw FetchResourceException(
|
||||
FetchResourceErrorType.ERR_NETWORK_ERROR
|
||||
)
|
||||
}
|
||||
}
|
||||
105
app/src/main/java/de/sebse/fuplanner2/blackboard/ExtEvents.kt
Normal file
105
app/src/main/java/de/sebse/fuplanner2/blackboard/ExtEvents.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
package de.sebse.fuplanner2.blackboard
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.VolleyError
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
import de.sebse.fuplanner2.database.*
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.utils.*
|
||||
import de.sebse.fuplanner2.worker.FetchResourceErrorType
|
||||
import de.sebse.fuplanner2.worker.FetchResourceException
|
||||
|
||||
|
||||
suspend fun Blackboard.getEvents(ctx: Context, database: AppDatabase, user: User, course: Course): UpdateResult<Event> {
|
||||
if (!isAvailable(user)) return UpdateResult()
|
||||
if (course.moduleType != MODULE_TYPE) return UpdateResult()
|
||||
val courseId = course.uid ?: return UpdateResult()
|
||||
|
||||
val requester = Requester(ctx)
|
||||
val stored = database.eventDao().getAll2(courseId)
|
||||
|
||||
val latestSemester = database.courseDao().getLatestSemesterName()
|
||||
if (course.isSummerSemester != latestSemester.semester || course.year != latestSemester.year)
|
||||
return updateResultOf(stored, stored)
|
||||
|
||||
try {
|
||||
val new = course.lvNumber.map { lvNumber ->
|
||||
val vvNumber = Cache.getCache(database, lvNumber, "Blackboard_VVNumber", REFRESH_FREQUENCY_EVENT_CACHE_MILLIS) {
|
||||
requester.head(
|
||||
FETCH_VV_COURSE_ID.format(lvNumber),
|
||||
null
|
||||
).let {
|
||||
Regex("lv/([0-9]+)\\?")
|
||||
.find(it.headers["Location"] ?: "")
|
||||
?.groups
|
||||
?.get(1)
|
||||
?.value
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
val body = requester
|
||||
.get(FETCH_VV_COURSE.format(vvNumber), null)
|
||||
.body
|
||||
Regex("<span id=\"link_to_details_[^~]*?</span>")
|
||||
.findAll(body)
|
||||
.map event@{
|
||||
val (start, duration) = Regex("[A-Z][a-z], [0-9]{2}\\.[0-9]{2}\\.[0-9]{4} [0-9]{2}:[0-9]{2} - [0-9]{2}:[0-9]{2}")
|
||||
.find(it.value)
|
||||
?.value
|
||||
?.let { dateString ->
|
||||
val start = dateString.substring(4, 20)
|
||||
.dateStringToLong("dd.MM.yyyy HH:mm")
|
||||
?: return@event null
|
||||
val duration = (0L
|
||||
+ (dateString.substring(23, 25).toLongOrNull(10) ?: return@event null) * 60
|
||||
- (dateString.substring(15, 17).toLongOrNull(10) ?: return@event null) * 60
|
||||
+ (dateString.substring(26, 28).toLongOrNull(10) ?: return@event null) * 1
|
||||
- (dateString.substring(18, 20).toLongOrNull(10) ?: return@event null) * 1)
|
||||
Pair(start, duration * 60000)
|
||||
} ?: return@event null
|
||||
val title = Regex("<div class=\"course_title\">([^<]*?)</div>")
|
||||
.find(it.value)
|
||||
?.groups
|
||||
?.get(1)
|
||||
?.value
|
||||
?.trim()
|
||||
?: course.title
|
||||
val location = Regex("<div class=\"appointment_details_column\">[^~]*?</div>")
|
||||
.findAll(it.value)
|
||||
.map {
|
||||
if (it.value.contains("Räume:"))
|
||||
Regex("</b>([^~]*?)</p>")
|
||||
.findAll(it.value)
|
||||
.map { it.groups.get(1)?.value?.trim() }
|
||||
.filterNotNull()
|
||||
.toList()
|
||||
else
|
||||
listOf()
|
||||
}
|
||||
.flatten()
|
||||
.joinToString(separator = ", ")
|
||||
val type = Regex("<span class=\"[^\"]*?category[^\"]*?\">([^<]*)</span>")
|
||||
.find(it.value)
|
||||
?.groups
|
||||
?.get(1)
|
||||
?.value
|
||||
?.trim()
|
||||
?: "Class section - Lecture"
|
||||
Event(null, courseId, System.currentTimeMillis(), title, duration, start, location, type)
|
||||
}
|
||||
.toList()
|
||||
.filterNotNull()
|
||||
}.flatten()
|
||||
|
||||
val result = updateResultOf(stored, new)
|
||||
database.eventDao().run {
|
||||
delete(result.removed)
|
||||
upsert(result.values)
|
||||
}
|
||||
return result
|
||||
} catch (e: VolleyError) {
|
||||
val statusCode = e.networkResponse.statusCode
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_NETWORK_ERROR, statusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.navigation.NavController
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.CASCADE
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.beust.klaxon.JsonObject
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.ui.notification.NotificationFragmentDirections
|
||||
import de.sebse.fuplanner2.utils.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(onDelete = CASCADE, entity = Course::class, parentColumns = ["uid"], childColumns = ["courseId"])
|
||||
],
|
||||
indices = [
|
||||
Index("courseId"),
|
||||
Index("id", unique = true)
|
||||
]
|
||||
)
|
||||
data class Announcement (
|
||||
@PrimaryKey(autoGenerate = true) var uid: Long? = null,
|
||||
val courseId: Long,
|
||||
val lastRefreshed: Long,
|
||||
val id: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val createdOn: Long,
|
||||
val createdBy: String,
|
||||
var attachments: List<Attachment>
|
||||
): Updatable {
|
||||
override fun getIdentifier(): String =
|
||||
id
|
||||
|
||||
override fun getHash(): Int =
|
||||
hashCodeOf(title, body, createdOn, createdBy, attachments)
|
||||
|
||||
override fun getData(): JsonObject =
|
||||
JsonObject(mapOf("title" to title, "uid" to uid))
|
||||
|
||||
override fun getNotificationEntityType(): Notifications.CourseUpdateEntity =
|
||||
Notifications.CourseUpdateEntity.ANNOUNCEMENT
|
||||
|
||||
companion object : UpdatableCompanion {
|
||||
override fun notificationText(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject
|
||||
): CharSequence? = when (type) {
|
||||
Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.not_course_update_announcement_removed, data.string("title"))
|
||||
Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.not_course_update_announcement_updated, data.string("title"))
|
||||
Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.not_course_update_announcement_added, data.string("title"))
|
||||
}
|
||||
|
||||
override fun adapterText(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject
|
||||
): CharSequence? = when (type) {
|
||||
Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_announcement_removed, data.string("title"))
|
||||
Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_announcement_updated, data.string("title"))
|
||||
Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_announcement_added, data.string("title"))
|
||||
}
|
||||
|
||||
override fun adapterCallback(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject,
|
||||
navController: NavController
|
||||
): () -> Unit {
|
||||
return {
|
||||
GlobalScope.launch {
|
||||
data.long("uid")
|
||||
?.let { AppDatabase.getInstance().announcementDao().getAnnouncementById(it) }
|
||||
?.let { AppDatabase.getInstance().courseDao().getCourseById2(it.courseId) }
|
||||
?.let {
|
||||
withContext(Dispatchers.Main) {
|
||||
navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.paging.DataSource
|
||||
import androidx.room.*
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
|
||||
@Dao
|
||||
interface AnnouncementDao {
|
||||
@Query("SELECT * FROM announcement WHERE courseId = :courseId ORDER BY createdOn ASC")
|
||||
fun getAll1(courseId: Long): DataSource.Factory<Int, Announcement>
|
||||
|
||||
@Query("SELECT * FROM announcement WHERE courseId = :courseId ORDER BY createdOn ASC")
|
||||
fun getAll2(courseId: Long): List<Announcement>
|
||||
|
||||
@Query("SELECT * FROM announcement WHERE uid = :announcementId LIMIT 1")
|
||||
fun getAnnouncementById(announcementId: Long): Announcement
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(announcement: Announcement): Long
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun update(announcement: Announcement)
|
||||
|
||||
@Transaction
|
||||
fun upsert(announcement: Announcement) {
|
||||
val id = insert(announcement)
|
||||
if (id == -1L) {
|
||||
update(announcement)
|
||||
} else {
|
||||
announcement.uid = id
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun upsert(announcements: List<Announcement>) {
|
||||
announcements.forEach { announcement ->
|
||||
upsert(announcement)
|
||||
}
|
||||
}
|
||||
|
||||
@Delete
|
||||
fun delete(announcement: Announcement)
|
||||
|
||||
@Delete
|
||||
fun delete(announcements: List<Announcement>)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
User::class, Course::class, Cache::class,
|
||||
Notification::class, Event::class, Announcement::class
|
||||
],
|
||||
version = 1
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
abstract class AppDatabase: RoomDatabase() {
|
||||
abstract fun userDao(): UserDao
|
||||
abstract fun courseDao(): CourseDao
|
||||
abstract fun cacheDao(): CacheDao
|
||||
abstract fun notificationDao(): NotificationDao
|
||||
abstract fun eventDao(): EventDao
|
||||
abstract fun announcementDao(): AnnouncementDao
|
||||
|
||||
private val mIsDatabaseCreated = MutableLiveData<Boolean>()
|
||||
|
||||
companion object {
|
||||
@VisibleForTesting
|
||||
val DATABASE_NAME = "app-db"
|
||||
|
||||
private var sInstance: AppDatabase? = null
|
||||
|
||||
fun initialize(context: Context): AppDatabase? {
|
||||
if (sInstance == null) {
|
||||
synchronized(AppDatabase::class.java) {
|
||||
if (sInstance == null) {
|
||||
sInstance = buildDatabase(context.applicationContext)
|
||||
sInstance?.updateDatabaseCreated(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sInstance
|
||||
}
|
||||
|
||||
fun getInstance(): AppDatabase {
|
||||
sInstance?.let {
|
||||
return it
|
||||
}
|
||||
throw NullPointerException("Please call initialize() before getting the instance.")
|
||||
}
|
||||
|
||||
private fun buildDatabase(
|
||||
appContext: Context
|
||||
): AppDatabase {
|
||||
return Room.databaseBuilder(appContext, AppDatabase::class.java, DATABASE_NAME)
|
||||
.addCallback(object : Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
super.onCreate(db)
|
||||
val database = getInstance()
|
||||
database.setDatabaseCreated()
|
||||
}
|
||||
}).build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDatabaseCreated(context: Context) {
|
||||
if (context.getDatabasePath(DATABASE_NAME).exists()) {
|
||||
setDatabaseCreated()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setDatabaseCreated() {
|
||||
mIsDatabaseCreated.postValue(true)
|
||||
}
|
||||
|
||||
fun getDatabaseCreated(): LiveData<Boolean?> {
|
||||
return mIsDatabaseCreated
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/de/sebse/fuplanner2/database/Attachment.kt
Normal file
25
app/src/main/java/de/sebse/fuplanner2/database/Attachment.kt
Normal file
@@ -0,0 +1,25 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import com.beust.klaxon.Klaxon
|
||||
import com.beust.klaxon.KlaxonException
|
||||
|
||||
|
||||
data class Attachment (
|
||||
val url: String,
|
||||
val name: String,
|
||||
val mimeType: String
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return Klaxon().toJsonString(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromString(json: String): Attachment? {
|
||||
return try {
|
||||
Klaxon().parse<Attachment>(json)
|
||||
} catch (e: KlaxonException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/src/main/java/de/sebse/fuplanner2/database/Cache.kt
Normal file
30
app/src/main/java/de/sebse/fuplanner2/database/Cache.kt
Normal file
@@ -0,0 +1,30 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.room.Entity
|
||||
|
||||
@Entity(primaryKeys = ["uid", "type"])
|
||||
data class Cache (
|
||||
val uid: String,
|
||||
val type: String,
|
||||
val lastRefreshed: Long,
|
||||
val cache: String
|
||||
) {
|
||||
companion object {
|
||||
suspend fun getCache(database: AppDatabase, uid: String, type: String, refreshInterval: Long, onCreateNew: (suspend () -> String)): String {
|
||||
val cacheDao = database.cacheDao()
|
||||
val cache = cacheDao.getCache(uid, type)?.let {
|
||||
if (System.currentTimeMillis() <= it.lastRefreshed + refreshInterval) {
|
||||
it
|
||||
} else {
|
||||
cacheDao.delete(it)
|
||||
null
|
||||
}
|
||||
} ?: run {
|
||||
val c = Cache(uid, type, System.currentTimeMillis(), onCreateNew())
|
||||
cacheDao.insert(c)
|
||||
c
|
||||
}
|
||||
return cache.cache
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/src/main/java/de/sebse/fuplanner2/database/CacheDao.kt
Normal file
15
app/src/main/java/de/sebse/fuplanner2/database/CacheDao.kt
Normal file
@@ -0,0 +1,15 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface CacheDao {
|
||||
@Query("SELECT * FROM cache WHERE uid = :uid AND type = :type")
|
||||
fun getCache(uid: String, type: String): Cache?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun insert(cache: Cache)
|
||||
|
||||
@Delete()
|
||||
fun delete(cache: Cache)
|
||||
}
|
||||
88
app/src/main/java/de/sebse/fuplanner2/database/Converters.kt
Normal file
88
app/src/main/java/de/sebse/fuplanner2/database/Converters.kt
Normal file
@@ -0,0 +1,88 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import de.sebse.fuplanner2.auth.UserCookies
|
||||
import de.sebse.fuplanner2.blackboard.BlackboardInfo
|
||||
import de.sebse.fuplanner2.whiteboard.WhiteboardInfo
|
||||
import java.util.*
|
||||
|
||||
class Converters {
|
||||
private val delimiter = "~@@~"
|
||||
|
||||
@TypeConverter
|
||||
fun fromUserCookies(value: UserCookies?): String? {
|
||||
return value?.toString()
|
||||
}
|
||||
@TypeConverter
|
||||
fun toUserCookies(value: String?): UserCookies? {
|
||||
return value?.let { UserCookies(it) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@TypeConverter
|
||||
fun fromBlackboardInfo(value: BlackboardInfo?): String? {
|
||||
return value?.toString()
|
||||
}
|
||||
@TypeConverter
|
||||
fun toBlackboardInfo(value: String?): BlackboardInfo? {
|
||||
return value?.let { BlackboardInfo(it) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@TypeConverter
|
||||
fun fromWhiteboardInfo(value: WhiteboardInfo?): String? {
|
||||
return value?.toString()
|
||||
}
|
||||
@TypeConverter
|
||||
fun toWhiteboardInfo(value: String?): WhiteboardInfo? {
|
||||
return value?.let { WhiteboardInfo(it) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@TypeConverter
|
||||
fun fromDate(value: Date?): Long? {
|
||||
return value?.time
|
||||
}
|
||||
@TypeConverter
|
||||
fun toDate(value: Long?): Date? {
|
||||
return value?.let { Date(it) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringSet(value: HashSet<String>?): String? {
|
||||
return value?.joinToString(separator = delimiter)
|
||||
}
|
||||
@TypeConverter
|
||||
fun toStringSet(value: String?): HashSet<String>? {
|
||||
return value?.split(delimiter)?.toHashSet()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@TypeConverter
|
||||
fun fromLecturerList(value: List<Lecturer>?): String? {
|
||||
return value?.joinToString(separator = delimiter) { it.toJsonString() }
|
||||
}
|
||||
@TypeConverter
|
||||
fun toLecturerList(value: String?): List<Lecturer>? {
|
||||
return value?.split(delimiter)?.mapNotNull { Lecturer.fromString(it) }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@TypeConverter
|
||||
fun fromAttachmentList(value: List<Attachment>?): String? {
|
||||
return value?.joinToString(separator = delimiter) { it.toJsonString() }
|
||||
}
|
||||
@TypeConverter
|
||||
fun toAttachmentList(value: String?): List<Attachment>? {
|
||||
return value?.split(delimiter)?.mapNotNull { Attachment.fromString(it) }
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
101
app/src/main/java/de/sebse/fuplanner2/database/Course.kt
Normal file
101
app/src/main/java/de/sebse/fuplanner2/database/Course.kt
Normal file
@@ -0,0 +1,101 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.navigation.NavController
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.CASCADE
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.beust.klaxon.JsonObject
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.ui.notification.NotificationFragmentDirections
|
||||
import de.sebse.fuplanner2.utils.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(onDelete = CASCADE, entity = User::class, parentColumns = ["uid"], childColumns = ["userId"])
|
||||
],
|
||||
indices = [
|
||||
Index("userId"),
|
||||
Index("internalId", "moduleType", unique = true)
|
||||
]
|
||||
)
|
||||
data class Course (
|
||||
@PrimaryKey(autoGenerate = true) var uid: Long? = null,
|
||||
val userId: Long,
|
||||
val lastRefreshed: Long,
|
||||
val isSummerSemester: Boolean,
|
||||
val year: Int?,
|
||||
val lvNumber: HashSet<String>,
|
||||
val title: String,
|
||||
val type: String,
|
||||
val description: String,
|
||||
val internalId: String,
|
||||
val moduleType: Int,
|
||||
val lecturers: List<Lecturer>
|
||||
): Comparable<Course>, Updatable {
|
||||
override fun getIdentifier(): String =
|
||||
internalId
|
||||
|
||||
override fun getHash(): Int =
|
||||
hashCodeOf(isSummerSemester, year, lvNumber, title, type, description)
|
||||
|
||||
override fun getData(): JsonObject =
|
||||
JsonObject(mapOf("title" to title, "uid" to uid))
|
||||
|
||||
override fun getNotificationEntityType(): Notifications.CourseUpdateEntity =
|
||||
Notifications.CourseUpdateEntity.COURSE
|
||||
|
||||
override fun compareTo(other: Course): Int {
|
||||
(year ?: Int.MIN_VALUE).compareTo(other.year ?: Int.MIN_VALUE).let {
|
||||
if (it != 0) return it
|
||||
return other.isSummerSemester.compareTo(isSummerSemester)
|
||||
}
|
||||
}
|
||||
|
||||
companion object : UpdatableCompanion {
|
||||
override fun notificationText(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject
|
||||
): CharSequence? = when (type) {
|
||||
Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_removed, data.string("title"))
|
||||
Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_updated, data.string("title"))
|
||||
Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.not_course_update_course_added, data.string("title"))
|
||||
}
|
||||
|
||||
override fun adapterText(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject
|
||||
): CharSequence? = when (type) {
|
||||
Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_removed, data.string("title"))
|
||||
Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_updated, data.string("title"))
|
||||
Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_course_added, data.string("title"))
|
||||
}
|
||||
|
||||
override fun adapterCallback(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject,
|
||||
navController: NavController
|
||||
): () -> Unit {
|
||||
return {
|
||||
GlobalScope.launch {
|
||||
data.long("uid")
|
||||
?.let { AppDatabase.getInstance().courseDao().getCourseById2(it) }
|
||||
?.let {
|
||||
withContext(Dispatchers.Main) {
|
||||
navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
app/src/main/java/de/sebse/fuplanner2/database/CourseDao.kt
Normal file
56
app/src/main/java/de/sebse/fuplanner2/database/CourseDao.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface CourseDao {
|
||||
@Query("SELECT * FROM course ORDER BY year DESC, CASE WHEN isSummerSemester THEN 1 ELSE 0 END ASC, title ASC")
|
||||
fun getAll(): LiveData<List<Course>>
|
||||
|
||||
@Query("SELECT * FROM course INNER JOIN (SELECT year, CASE WHEN isSummerSemester THEN 1 ELSE 0 END AS semester FROM course GROUP BY year, isSummerSemester ORDER BY year DESC, semester ASC LIMIT 1) current WHERE current.year = course.year AND current.semester = CASE WHEN course.isSummerSemester THEN 1 ELSE 0 END ORDER BY title ASC")
|
||||
fun getLatestSemester(): LiveData<List<Course>>
|
||||
|
||||
@Query("SELECT year, CASE WHEN isSummerSemester THEN 1 ELSE 0 END AS semester FROM course GROUP BY year, isSummerSemester ORDER BY year DESC, semester ASC LIMIT 1")
|
||||
fun getLatestSemesterName(): Semester
|
||||
|
||||
@Query("SELECT * FROM course WHERE uid = :courseId")
|
||||
fun getCourseById(courseId: Long): LiveData<Course>
|
||||
|
||||
@Query("SELECT * FROM course WHERE uid = :courseId")
|
||||
fun getCourseById2(courseId: Long): Course
|
||||
|
||||
@Query("SELECT * FROM course WHERE userId = :userId AND (moduleType & :moduleType) == :moduleType")
|
||||
fun getAllByType(userId: Long, moduleType: Int): List<Course>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(course: Course): Long
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun update(course: Course)
|
||||
|
||||
@Transaction
|
||||
fun upsert(course: Course) {
|
||||
val id = insert(course)
|
||||
if (id == -1L) {
|
||||
update(course)
|
||||
} else {
|
||||
course.uid = id
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun upsert(courses: List<Course>) {
|
||||
courses.forEach { course ->
|
||||
upsert(course)
|
||||
}
|
||||
}
|
||||
|
||||
@Delete
|
||||
fun delete(course: Course)
|
||||
|
||||
@Delete
|
||||
fun delete(courses: List<Course>)
|
||||
}
|
||||
|
||||
data class Semester(val year: Int, val semester: Boolean)
|
||||
91
app/src/main/java/de/sebse/fuplanner2/database/Event.kt
Normal file
91
app/src/main/java/de/sebse/fuplanner2/database/Event.kt
Normal file
@@ -0,0 +1,91 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.navigation.NavController
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.ForeignKey.CASCADE
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import com.beust.klaxon.JsonObject
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.ui.notification.NotificationFragmentDirections
|
||||
import de.sebse.fuplanner2.utils.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Entity(
|
||||
foreignKeys = [
|
||||
ForeignKey(onDelete = CASCADE, entity = Course::class, parentColumns = ["uid"], childColumns = ["courseId"])
|
||||
],
|
||||
indices = [
|
||||
Index("courseId"),
|
||||
Index("courseId", "startDateTime", "duration", "title", "location", unique = true)
|
||||
]
|
||||
)
|
||||
data class Event (
|
||||
@PrimaryKey(autoGenerate = true) var uid: Long? = null,
|
||||
val courseId: Long,
|
||||
val lastRefreshed: Long,
|
||||
val title: String,
|
||||
val duration: Long,
|
||||
val startDateTime: Long,
|
||||
val location: String?,
|
||||
val type: String
|
||||
): Updatable {
|
||||
override fun getIdentifier(): String =
|
||||
hashCodeOf(type, startDateTime, duration, title, location).toString()
|
||||
|
||||
override fun getHash(): Int =
|
||||
hashCodeOf(type, startDateTime, duration, title, location)
|
||||
|
||||
override fun getData(): JsonObject =
|
||||
JsonObject(mapOf("title" to title, "uid" to uid))
|
||||
|
||||
override fun getNotificationEntityType(): Notifications.CourseUpdateEntity =
|
||||
Notifications.CourseUpdateEntity.EVENT
|
||||
|
||||
companion object : UpdatableCompanion {
|
||||
override fun notificationText(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject
|
||||
): CharSequence? = when (type) {
|
||||
Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.not_course_update_event_removed, data.string("title"))
|
||||
Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.not_course_update_event_updated, data.string("title"))
|
||||
Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.not_course_update_event_added, data.string("title"))
|
||||
}
|
||||
|
||||
override fun adapterText(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject
|
||||
): CharSequence? = when (type) {
|
||||
Notifications.CourseUpdateType.REMOVED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_event_removed, data.string("title"))
|
||||
Notifications.CourseUpdateType.UPDATED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_event_updated, data.string("title"))
|
||||
Notifications.CourseUpdateType.ADDED -> actCtx.getHtmlSpannedString(R.string.adapter_course_update_event_added, data.string("title"))
|
||||
}
|
||||
|
||||
override fun adapterCallback(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject,
|
||||
navController: NavController
|
||||
): () -> Unit {
|
||||
return {
|
||||
GlobalScope.launch {
|
||||
data.long("uid")
|
||||
?.let { AppDatabase.getInstance().eventDao().getEventById(it) }
|
||||
?.let { AppDatabase.getInstance().courseDao().getCourseById2(it.courseId) }
|
||||
?.let {
|
||||
withContext(Dispatchers.Main) {
|
||||
navController.navigate(NotificationFragmentDirections.actionNavNotificationsToCourseDetails(it.uid!!, it.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
app/src/main/java/de/sebse/fuplanner2/database/EventDao.kt
Normal file
49
app/src/main/java/de/sebse/fuplanner2/database/EventDao.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.paging.DataSource
|
||||
import androidx.room.*
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
|
||||
@Dao
|
||||
interface EventDao {
|
||||
@Query("SELECT * FROM event WHERE startDateTime >= :start AND startDateTime <= :end")
|
||||
fun getAllBetween(start: Long, end: Long): List<Event>
|
||||
|
||||
@Query("SELECT * FROM event WHERE courseId = :courseId ORDER BY startDateTime ASC")
|
||||
fun getAll1(courseId: Long): DataSource.Factory<Int, Event>
|
||||
|
||||
@Query("SELECT * FROM event WHERE courseId = :courseId ORDER BY startDateTime ASC")
|
||||
fun getAll2(courseId: Long): List<Event>
|
||||
|
||||
@Query("SELECT * FROM event WHERE uid = :eventId LIMIT 1")
|
||||
fun getEventById(eventId: Long): Event
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(event: Event): Long
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun update(event: Event)
|
||||
|
||||
@Transaction
|
||||
fun upsert(event: Event) {
|
||||
val id = insert(event)
|
||||
if (id == -1L) {
|
||||
update(event)
|
||||
} else {
|
||||
event.uid = id
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun upsert(events: List<Event>) {
|
||||
events.forEach { event ->
|
||||
upsert(event)
|
||||
}
|
||||
}
|
||||
|
||||
@Delete
|
||||
fun delete(event: Event)
|
||||
|
||||
@Delete
|
||||
fun delete(events: List<Event>)
|
||||
}
|
||||
26
app/src/main/java/de/sebse/fuplanner2/database/Lecturer.kt
Normal file
26
app/src/main/java/de/sebse/fuplanner2/database/Lecturer.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import com.beust.klaxon.Klaxon
|
||||
import com.beust.klaxon.KlaxonException
|
||||
|
||||
|
||||
data class Lecturer (
|
||||
val fistName: String,
|
||||
val lastName: String,
|
||||
val email: String,
|
||||
val isResponsible: Boolean
|
||||
) {
|
||||
fun toJsonString(): String {
|
||||
return Klaxon().toJsonString(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromString(json: String): Lecturer? {
|
||||
return try {
|
||||
Klaxon().parse<Lecturer>(json)
|
||||
} catch (e: KlaxonException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
|
||||
@Entity
|
||||
data class Notification (
|
||||
@PrimaryKey(autoGenerate = true) val uid: Int? = null,
|
||||
val created: Long,
|
||||
val read: Boolean,
|
||||
val channelId: String,
|
||||
val data: String
|
||||
) {
|
||||
fun getJsonData(): JsonObject? {
|
||||
return try {
|
||||
Parser.default().parse(StringBuilder(this.data)) as JsonObject
|
||||
} catch (e: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.paging.DataSource
|
||||
import androidx.room.*
|
||||
|
||||
|
||||
@Dao
|
||||
interface NotificationDao {
|
||||
@Query("SELECT * FROM notification ORDER BY created DESC")
|
||||
fun getAllPaged(): DataSource.Factory<Int, Notification>
|
||||
|
||||
@Query("SELECT * FROM notification WHERE channelId = :channelId AND read = 0")
|
||||
fun loadAllUnreadByChannelId(channelId: String): List<Notification>
|
||||
|
||||
@Query("SELECT COUNT(uid) FROM notification WHERE read = 0")
|
||||
fun getUnreadRowCount(): LiveData<Int>
|
||||
|
||||
@Query("UPDATE notification SET read = 1")
|
||||
fun setRead()
|
||||
|
||||
@Query("UPDATE notification SET read = 1 WHERE uid = :notificationId")
|
||||
fun setRead(notificationId: Int)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(notification: Notification): Long
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun update(notification: Notification)
|
||||
|
||||
@Transaction
|
||||
fun upsert(notification: Notification) {
|
||||
val id = insert(notification)
|
||||
if (id == -1L) {
|
||||
update(notification)
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
fun upsert(notifications: List<Notification>) {
|
||||
notifications.forEach { notification ->
|
||||
upsert(notification)
|
||||
}
|
||||
}
|
||||
|
||||
@Delete
|
||||
fun delete(notification: Notification)
|
||||
|
||||
@Query("DELETE FROM notification WHERE created < (STRFTIME('%s', 'now') * 1000 - 31536000000) AND read")
|
||||
fun deleteOld()
|
||||
|
||||
}
|
||||
21
app/src/main/java/de/sebse/fuplanner2/database/User.kt
Normal file
21
app/src/main/java/de/sebse/fuplanner2/database/User.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import de.sebse.fuplanner2.auth.UserCookies
|
||||
import de.sebse.fuplanner2.blackboard.BlackboardInfo
|
||||
import de.sebse.fuplanner2.whiteboard.WhiteboardInfo
|
||||
|
||||
@Entity
|
||||
data class User (
|
||||
@PrimaryKey(autoGenerate = true) var uid: Long? = null,
|
||||
@ColumnInfo(name = "cookies") val cookies: UserCookies,
|
||||
@ColumnInfo(name = "bbInfo") val bbInfo: BlackboardInfo,
|
||||
@ColumnInfo(name = "wbInfo") val wbInfo: WhiteboardInfo,
|
||||
@ColumnInfo(name = "username") val userName: String,
|
||||
@ColumnInfo(name = "first_name") var firstName: String? = null,
|
||||
@ColumnInfo(name = "last_name") var lastName: String? = null,
|
||||
@ColumnInfo(name = "mat_number") var matNumber: Int? = null,
|
||||
@ColumnInfo(name = "email") var email: String? = null
|
||||
)
|
||||
41
app/src/main/java/de/sebse/fuplanner2/database/UserDao.kt
Normal file
41
app/src/main/java/de/sebse/fuplanner2/database/UserDao.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package de.sebse.fuplanner2.database
|
||||
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface UserDao {
|
||||
@Query("SELECT * FROM user")
|
||||
fun getAll(): List<User>
|
||||
|
||||
@Query("SELECT * FROM user WHERE uid IN (:userIds)")
|
||||
fun loadAllByIds(userIds: IntArray): List<User>
|
||||
|
||||
@Query("SELECT * FROM user WHERE first_name LIKE :first AND last_name LIKE :last LIMIT 1")
|
||||
fun findByName(first: String, last: String): User?
|
||||
|
||||
@Query("SELECT * FROM user WHERE username LIKE :name LIMIT 1")
|
||||
fun findByUsername(name: String): User?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
fun insert(user: User): Long
|
||||
|
||||
@Update(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun update(user: User)
|
||||
|
||||
@Transaction
|
||||
fun upsert(user: User) {
|
||||
val id = insert(user)
|
||||
if (id == -1L) {
|
||||
update(user)
|
||||
} else {
|
||||
user.uid = id
|
||||
}
|
||||
}
|
||||
|
||||
@Delete
|
||||
fun delete(user: User)
|
||||
|
||||
@Query("DELETE FROM user WHERE username = :userName")
|
||||
fun deleteByUserName(userName: String)
|
||||
|
||||
}
|
||||
126
app/src/main/java/de/sebse/fuplanner2/network/CustomRequest.kt
Normal file
126
app/src/main/java/de/sebse/fuplanner2/network/CustomRequest.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
package de.sebse.fuplanner2.network
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.GuardedBy
|
||||
import com.android.volley.*
|
||||
import com.android.volley.toolbox.HttpHeaderParser
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import java.io.UnsupportedEncodingException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
|
||||
class CustomRequest: Request<NetData> {
|
||||
/** Lock to guard mListener as it is cleared on cancel() and read on delivery. */
|
||||
private val mLock = Any()
|
||||
|
||||
@GuardedBy("mLock")
|
||||
private var mListener: Response.Listener<NetData>? = null
|
||||
|
||||
private var mCookies: Map<String, String>?
|
||||
private var mData: Map<String, String>?
|
||||
|
||||
constructor(
|
||||
method: Int,
|
||||
url: String?,
|
||||
cookies: Map<String, String>?,
|
||||
data: Map<String, String>?,
|
||||
listener: Response.Listener<NetData>?, errorListener: Response.ErrorListener?
|
||||
) : super(method, url, errorListener) {
|
||||
mListener = listener
|
||||
mCookies = cookies
|
||||
mData = data
|
||||
}
|
||||
|
||||
constructor(
|
||||
method: Int,
|
||||
url: String?,
|
||||
cookies: Map<String, String>?,
|
||||
listener: Response.Listener<NetData>?, errorListener: Response.ErrorListener?
|
||||
) : this(method, url, cookies, null, listener, errorListener)
|
||||
|
||||
override fun cancel() {
|
||||
super.cancel()
|
||||
synchronized(mLock) { mListener = null }
|
||||
}
|
||||
|
||||
override fun deliverResponse(response: NetData) {
|
||||
var listener: Response.Listener<NetData>?
|
||||
synchronized(mLock) { listener = mListener }
|
||||
listener?.onResponse(response)
|
||||
}
|
||||
|
||||
override fun deliverError(error: VolleyError?) {
|
||||
val volleyError = if (error == null) {
|
||||
VolleyError(NetworkResponse(408, null, true, 0, null))
|
||||
} else if (error.networkResponse == null) {
|
||||
val statusCode: Int = if (error is TimeoutError) 408 else 500
|
||||
VolleyError(NetworkResponse(statusCode, null, true, error.networkTimeMs, null))
|
||||
} else {
|
||||
if (error.networkResponse.statusCode == 302) {
|
||||
deliverResponse(parseNetworkResponse(error.networkResponse)?.result ?: NetData("", NetworkResponse(null)))
|
||||
null
|
||||
} else {
|
||||
error
|
||||
}
|
||||
}
|
||||
volleyError?.let {
|
||||
console.error("RequestError", "${it.networkResponse.statusCode} on $method $url")
|
||||
super.deliverError(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun parseNetworkResponse(response: NetworkResponse): Response<NetData>? {
|
||||
val parsed: String = parse(response.data, response.headers) ?: ""
|
||||
return Response.success(NetData(parsed, response), HttpHeaderParser.parseCacheHeaders(response))
|
||||
}
|
||||
|
||||
override fun getHeaders(): MutableMap<String, String> {
|
||||
var params =
|
||||
super.getHeaders()
|
||||
mCookies?.let { cookieMap ->
|
||||
params = params?.let { HashMap(it) } ?: HashMap()
|
||||
val newStr = ArrayList<String>()
|
||||
for (key in cookieMap.keys) newStr.add("$key=${cookieMap[key]}")
|
||||
params["Cookie"] = newStr.joinToString("; ")
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
override fun getBodyContentType(): String? {
|
||||
return "application/x-www-form-urlencoded"
|
||||
}
|
||||
|
||||
override fun getBody(): ByteArray? {
|
||||
mData?.let { data ->
|
||||
val sb = StringBuilder()
|
||||
for (key in data.keys) {
|
||||
if (sb.isNotEmpty()) {
|
||||
sb.append('&')
|
||||
}
|
||||
try {
|
||||
sb.append(URLEncoder.encode(key, "UTF-8")).append('=')
|
||||
.append(URLEncoder.encode(data[key], "UTF-8"))
|
||||
} catch (ignored: UnsupportedEncodingException) {
|
||||
}
|
||||
}
|
||||
val requestBody = sb.toString()
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
|
||||
requestBody.toByteArray(StandardCharsets.UTF_8)
|
||||
} else {
|
||||
requestBody.toByteArray()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parse(body: ByteArray?, headers: Map<String, String>): String? {
|
||||
return if (body == null) null else try {
|
||||
String(body, Charset.forName(HttpHeaderParser.parseCharset(headers)))
|
||||
} catch (e: UnsupportedEncodingException) {
|
||||
String(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
6
app/src/main/java/de/sebse/fuplanner2/network/NetData.kt
Normal file
6
app/src/main/java/de/sebse/fuplanner2/network/NetData.kt
Normal file
@@ -0,0 +1,6 @@
|
||||
package de.sebse.fuplanner2.network
|
||||
import com.android.volley.NetworkResponse
|
||||
|
||||
data class NetData(val body: String, val networkResponse: NetworkResponse) {
|
||||
val headers = networkResponse.headers
|
||||
}
|
||||
52
app/src/main/java/de/sebse/fuplanner2/network/Requester.kt
Normal file
52
app/src/main/java/de/sebse/fuplanner2/network/Requester.kt
Normal file
@@ -0,0 +1,52 @@
|
||||
package de.sebse.fuplanner2.network
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.android.volley.Request
|
||||
import com.android.volley.RequestQueue
|
||||
import com.android.volley.Response
|
||||
import com.android.volley.toolbox.HttpStack
|
||||
import com.android.volley.toolbox.HurlStack
|
||||
import com.android.volley.toolbox.Volley
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.AccessController.getContext
|
||||
import java.security.KeyManagementException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
|
||||
class Requester(ctx: Context) {
|
||||
private var requestQueue: RequestQueue = Volley.newRequestQueue(ctx, object : HurlStack() {
|
||||
@Throws(IOException::class)
|
||||
override fun createConnection(url: URL?): HttpURLConnection? {
|
||||
val connection: HttpURLConnection = super.createConnection(url)
|
||||
connection.instanceFollowRedirects = false
|
||||
return connection
|
||||
}
|
||||
})
|
||||
|
||||
suspend fun get(url: String, cookies: Map<String, String>?): NetData {
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val request = CustomRequest(Request.Method.GET, url, cookies, Response.Listener { response -> cont.resume(response) }, Response.ErrorListener { error -> cont.cancel(error) })
|
||||
requestQueue.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun head(url: String, cookies: Map<String, String>?): NetData {
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val request = CustomRequest(Request.Method.HEAD, url, cookies, Response.Listener { response -> cont.resume(response) }, Response.ErrorListener { error -> cont.cancel(error) })
|
||||
requestQueue.add(request)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun post(url: String, cookies: Map<String, String>?, data: Map<String, String>?): NetData {
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val request = CustomRequest(Request.Method.POST, url, cookies, data, Response.Listener { response -> cont.resume(response) }, Response.ErrorListener { error -> cont.cancel(error) })
|
||||
requestQueue.add(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/de/sebse/fuplanner2/network/tools.kt
Normal file
17
app/src/main/java/de/sebse/fuplanner2/network/tools.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package de.sebse.fuplanner2.network
|
||||
|
||||
import com.android.volley.NetworkResponse
|
||||
import com.android.volley.VolleyError
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
|
||||
object tools {
|
||||
fun invalidResponse(uid: Int, status: String): VolleyError {
|
||||
console.warn("InvalidResponse", "$uid - $status")
|
||||
return VolleyError(NetworkResponse(422, null, true, 0, null))
|
||||
}
|
||||
|
||||
fun invalidPassword(uid: Int, status: String): VolleyError {
|
||||
console.warn("InvalidPassword", "$uid - $status")
|
||||
return VolleyError(NetworkResponse(403, null, true, 0, null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.sebse.fuplanner2.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.SharedPreferences
|
||||
|
||||
class AppPreferences(val context: Context) {
|
||||
private val preferences: SharedPreferences = context.getSharedPreferences(APP_PREFERENCES_SCREEN, MODE_PRIVATE)
|
||||
|
||||
fun remove(key: String) = preferences.edit().remove(key).apply()
|
||||
|
||||
fun getString(key: String): String? = preferences.getString(key, null)
|
||||
fun set(key: String, value: String) = preferences.edit().putString(key, value).apply()
|
||||
|
||||
fun getBoolean(key: String): Boolean? = if (preferences.contains(key)) preferences.getBoolean(key, false) else null
|
||||
fun set(key: String, value: Boolean) = preferences.edit().putBoolean(key, value).apply()
|
||||
|
||||
fun getInt(key: String): Int? = if (preferences.contains(key)) preferences.getInt(key, 0) else null
|
||||
fun set(key: String, value: Int) = preferences.edit().putInt(key, value).apply()
|
||||
|
||||
fun getLong(key: String): Long? = if (preferences.contains(key)) preferences.getLong(key, 0) else null
|
||||
fun set(key: String, value: Long) = preferences.edit().putLong(key, value).apply()
|
||||
|
||||
companion object {
|
||||
const val APP_PREFERENCES_SCREEN = "app"
|
||||
|
||||
private var sInstance: AppPreferences? = null
|
||||
|
||||
fun initialize(context: Context): AppPreferences? {
|
||||
if (sInstance == null) {
|
||||
synchronized(AppPreferences::class.java) {
|
||||
if (sInstance == null) {
|
||||
sInstance = AppPreferences(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sInstance
|
||||
}
|
||||
|
||||
fun getInstance(): AppPreferences {
|
||||
sInstance?.let {
|
||||
return it
|
||||
}
|
||||
throw NullPointerException("Please call initialize() before getting the instance.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.sebse.fuplanner2.ui.courses
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.ui.CaptionHolder
|
||||
import de.sebse.fuplanner2.ui.CustomHolder
|
||||
import de.sebse.fuplanner2.ui.ListItemHolder
|
||||
import de.sebse.fuplanner2.ui.ViewHolderGenerator
|
||||
import de.sebse.fuplanner2.utils.cast
|
||||
import java.util.*
|
||||
|
||||
class CoursesAdapter(private val onclick: (Course) -> Unit) : RecyclerView.Adapter<CustomHolder>() {
|
||||
|
||||
private val positionalData: ArrayList<Any> = arrayListOf()
|
||||
var dataset: List<Course> = listOf()
|
||||
set(value) {
|
||||
field = value
|
||||
positionalData.clear()
|
||||
var last: Course? = null
|
||||
dataset.forEach {
|
||||
if (last?.let { last -> it < last } != false)
|
||||
positionalData.add(Pair(it.isSummerSemester, it.year))
|
||||
positionalData.add(it)
|
||||
last = it
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomHolder {
|
||||
return ViewHolderGenerator.getHolderByType(parent, viewType)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: CustomHolder, position: Int) {
|
||||
// val viewType = getItemViewType(position)
|
||||
val res = holder.itemView.resources
|
||||
when (holder) {
|
||||
is CaptionHolder -> cast<Pair<Boolean, Int?>>(positionalData[position])?.let { (isSummer, year) ->
|
||||
holder.string.text = when {
|
||||
year == null -> res.getString(R.string.projects)
|
||||
isSummer -> res.getString(R.string.summer_semester, year)
|
||||
else -> res.getString(R.string.winter_semester, year, year+1)
|
||||
}
|
||||
}
|
||||
is ListItemHolder -> cast<Course>(positionalData[position])?.let {
|
||||
holder.title.text = it.title
|
||||
holder.subLeft.text = it.lecturers.filter { lecturer -> lecturer.isResponsible }.joinToString { lecturer ->
|
||||
res.getString(R.string.full_name, lecturer.fistName.substring(0, 1)+".", lecturer.lastName)
|
||||
}
|
||||
holder.subRight.text = it.type
|
||||
holder.itemView.setOnClickListener { _ -> this.onclick(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (positionalData[position]) {
|
||||
is Course -> ViewHolderGenerator.HolderType.ITEM.ordinal
|
||||
else -> ViewHolderGenerator.HolderType.HEADER.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
// Return the size of your dataset (invoked by the layout manager)
|
||||
override fun getItemCount() = positionalData.size
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package de.sebse.fuplanner2.ui.courses
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_ACCOUNT_NAME
|
||||
import de.sebse.fuplanner2.worker.CourseWorker
|
||||
import kotlinx.android.synthetic.main.fragment_refresh_recycler.view.*
|
||||
|
||||
class CoursesFragment : Fragment() {
|
||||
|
||||
private lateinit var coursesViewModel: CoursesViewModel
|
||||
private lateinit var navController: NavController
|
||||
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val viewManager = LinearLayoutManager(context)
|
||||
val viewAdapter = CoursesAdapter(this::onClick)
|
||||
coursesViewModel = ViewModelProvider(this).get(CoursesViewModel::class.java)
|
||||
return inflater.inflate(R.layout.fragment_refresh_recycler, container, false).apply {
|
||||
recycler_view.apply {
|
||||
//setHasFixedSize(true)
|
||||
layoutManager = viewManager
|
||||
adapter = viewAdapter
|
||||
}
|
||||
swipe_refresh_layout.setOnRefreshListener {
|
||||
val work = OneTimeWorkRequestBuilder<CourseWorker>()
|
||||
.setInputData(workDataOf(
|
||||
KEY_ACCOUNT_NAME to AppAccounts.getInstance().selectedAccount?.name
|
||||
))
|
||||
.build()
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.enqueue(work)
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.getWorkInfoByIdLiveData(work.id)
|
||||
.observe(viewLifecycleOwner, Observer {
|
||||
if (it.state.isFinished)
|
||||
swipe_refresh_layout.isRefreshing = false
|
||||
})
|
||||
}
|
||||
coursesViewModel.text.observe(viewLifecycleOwner, Observer {
|
||||
viewAdapter.dataset = it
|
||||
})
|
||||
navController = findNavController()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onClick(course: Course) {
|
||||
course.uid?.let {
|
||||
navController.navigate(CoursesFragmentDirections.actionNavHomeToCourseDetails(it, course.title))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.sebse.fuplanner2.ui.courses
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
|
||||
class CoursesViewModel : ViewModel() {
|
||||
val text: LiveData<List<Course>> = AppDatabase.getInstance().courseDao().getAll()
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.sebse.fuplanner2.ui.details
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.database.Lecturer
|
||||
import de.sebse.fuplanner2.ui.*
|
||||
import de.sebse.fuplanner2.utils.cast
|
||||
import java.util.*
|
||||
|
||||
class DetailsAdapter(private val onQuickLink: (ButtonTypes) -> Unit, private val onMailTo: (Lecturer) -> Unit) : RecyclerView.Adapter<CustomHolder>() {
|
||||
|
||||
enum class HeaderTypes {
|
||||
BUTTONS, LECTURER, ANNOUNCEMENTS, ASSIGNMENTS, EVENTS;
|
||||
}
|
||||
|
||||
enum class ButtonTypes {
|
||||
DESCRIPTION, RESOURCES, GRADEBOOK, ANNOUNCEMENTS, ASSIGNMENTS, EVENTS;
|
||||
}
|
||||
|
||||
private val positionalData: ArrayList<Any> = arrayListOf()
|
||||
|
||||
var lecturers: List<Lecturer> = listOf()
|
||||
set(value) {
|
||||
field = value
|
||||
updatePositionalData()
|
||||
}
|
||||
|
||||
private fun updatePositionalData() {
|
||||
positionalData.clear()
|
||||
positionalData.add(HeaderTypes.BUTTONS)
|
||||
positionalData.add(ViewHolderGenerator.HolderType.BUTTONS)
|
||||
positionalData.add(HeaderTypes.LECTURER)
|
||||
positionalData.addAll(lecturers.sortedBy { it.lastName }.sortedBy { !it.isResponsible })
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomHolder {
|
||||
return ViewHolderGenerator.getHolderByType(parent, viewType)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: CustomHolder, position: Int) {
|
||||
// val viewType = getItemViewType(position)
|
||||
val res = holder.itemView.resources
|
||||
when (holder) {
|
||||
is CaptionHolder -> cast<HeaderTypes>(positionalData[position])?.let { type ->
|
||||
holder.string.text = when (type) {
|
||||
HeaderTypes.BUTTONS -> res.getString(R.string.quick_links)
|
||||
HeaderTypes.LECTURER -> res.getString(R.string.lecturers)
|
||||
HeaderTypes.ANNOUNCEMENTS -> res.getString(R.string.announcements)
|
||||
HeaderTypes.ASSIGNMENTS -> res.getString(R.string.assignments)
|
||||
HeaderTypes.EVENTS -> res.getString(R.string.events)
|
||||
}
|
||||
}
|
||||
is QuickLinksHolder -> {
|
||||
holder.btnAnnouncements.setOnClickListener { this.onQuickLink(ButtonTypes.ANNOUNCEMENTS) }
|
||||
holder.btnAssignments.setOnClickListener { this.onQuickLink(ButtonTypes.ASSIGNMENTS) }
|
||||
holder.btnDescription.setOnClickListener { this.onQuickLink(ButtonTypes.DESCRIPTION) }
|
||||
holder.btnEvents.setOnClickListener { this.onQuickLink(ButtonTypes.EVENTS) }
|
||||
holder.btnGradebook.setOnClickListener { this.onQuickLink(ButtonTypes.GRADEBOOK) }
|
||||
holder.btnResources.setOnClickListener { this.onQuickLink(ButtonTypes.RESOURCES) }
|
||||
}
|
||||
is MailHolder -> cast<Lecturer>(positionalData[position])?.let { type ->
|
||||
holder.title.text = res.getString(R.string.full_name, type.fistName, type.lastName)
|
||||
holder.subLeft.text = type.email
|
||||
holder.itemView.setOnClickListener { this.onMailTo(type) }
|
||||
}
|
||||
is ListItemHolder -> when (positionalData[position]) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return when (positionalData[position]) {
|
||||
is HeaderTypes -> ViewHolderGenerator.HolderType.HEADER.ordinal
|
||||
ViewHolderGenerator.HolderType.BUTTONS -> ViewHolderGenerator.HolderType.BUTTONS.ordinal
|
||||
is Lecturer -> ViewHolderGenerator.HolderType.MAIL.ordinal
|
||||
else -> ViewHolderGenerator.HolderType.ITEM.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
// Return the size of your dataset (invoked by the layout manager)
|
||||
override fun getItemCount() = positionalData.size
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package de.sebse.fuplanner2.ui.details
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.work.workDataOf
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.database.Lecturer
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.utils.enqueueOneTimeWork
|
||||
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_ACCOUNT_NAME
|
||||
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_COURSE_ID
|
||||
import de.sebse.fuplanner2.worker.CourseWorker
|
||||
import kotlinx.android.synthetic.main.fragment_refresh_recycler.view.*
|
||||
|
||||
|
||||
class DetailsFragment : Fragment() {
|
||||
|
||||
private var title: String = ""
|
||||
private val args: DetailsFragmentArgs by navArgs()
|
||||
private val detailsViewModel: DetailsViewModel by viewModels { DetailsViewModelFactory(args.courseId) }
|
||||
private lateinit var navController: NavController
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val viewManager = LinearLayoutManager(context)
|
||||
val viewAdapter = DetailsAdapter(this::launchFragment, this::sendMail)
|
||||
return inflater.inflate(R.layout.fragment_refresh_recycler, container, false).apply {
|
||||
recycler_view.apply {
|
||||
setHasFixedSize(true)
|
||||
|
||||
layoutManager = viewManager
|
||||
adapter = viewAdapter
|
||||
}
|
||||
swipe_refresh_layout.setOnRefreshListener {
|
||||
enqueueOneTimeWork<CourseWorker>(context.applicationContext) {
|
||||
it.setInputData(workDataOf(
|
||||
KEY_ACCOUNT_NAME to AppAccounts.getInstance().selectedAccount?.name,
|
||||
KEY_COURSE_ID to args.courseId
|
||||
))
|
||||
}.observe(viewLifecycleOwner, Observer {
|
||||
if (it.state.isFinished)
|
||||
swipe_refresh_layout.isRefreshing = false
|
||||
})
|
||||
}
|
||||
detailsViewModel.course.observe(viewLifecycleOwner, Observer {
|
||||
viewAdapter.lecturers = it.lecturers
|
||||
this@DetailsFragment.title = it.title
|
||||
})
|
||||
navController = findNavController()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMail(lecturer: Lecturer) {
|
||||
val intent = Intent(Intent.ACTION_SENDTO)
|
||||
intent.type = "text/html"
|
||||
intent.data = Uri.fromParts("mailto", lecturer.email, null)
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, this.title)
|
||||
intent.putExtra(Intent.EXTRA_TEXT, getString(R.string.email_preview, lecturer.fistName, lecturer.lastName))
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.send_email, lecturer.fistName, lecturer.lastName)))
|
||||
}
|
||||
|
||||
private fun launchFragment(btnType: DetailsAdapter.ButtonTypes) {
|
||||
when (btnType) {
|
||||
DetailsAdapter.ButtonTypes.DESCRIPTION ->
|
||||
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToDescriptionFragment(args.courseId, title))
|
||||
DetailsAdapter.ButtonTypes.RESOURCES -> TODO()
|
||||
DetailsAdapter.ButtonTypes.GRADEBOOK -> TODO()
|
||||
DetailsAdapter.ButtonTypes.ANNOUNCEMENTS ->
|
||||
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToCourseAnnouncements(args.courseId, title))
|
||||
DetailsAdapter.ButtonTypes.ASSIGNMENTS -> TODO()
|
||||
DetailsAdapter.ButtonTypes.EVENTS ->
|
||||
this.navController.navigate(DetailsFragmentDirections.actionCourseDetailsToCourseEvents(args.courseId, title))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.sebse.fuplanner2.ui.details
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
|
||||
|
||||
class DetailsViewModelFactory(private val courseId: Long): ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T = DetailsViewModel(courseId) as T
|
||||
}
|
||||
|
||||
class DetailsViewModel(courseId: Long) : ViewModel() {
|
||||
val course: LiveData<Course> = AppDatabase.getInstance().courseDao().getCourseById(courseId)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package de.sebse.fuplanner2.ui.details_announcements
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import de.sebse.fuplanner2.database.Announcement
|
||||
import de.sebse.fuplanner2.ui.ListItemHolder
|
||||
import de.sebse.fuplanner2.utils.toDateTimeString
|
||||
|
||||
class AnnouncementsAdapter() : PagedListAdapter<Announcement, ListItemHolder>(EventDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemHolder {
|
||||
return ListItemHolder.invoke(parent)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ListItemHolder, position: Int) {
|
||||
val event = getItem(position)
|
||||
val actCtx = holder.itemView.context
|
||||
|
||||
event?.let {
|
||||
holder.title.text = it.title
|
||||
holder.subLeft.text = it.createdOn.toDateTimeString(actCtx)
|
||||
holder.subRight.text = it.createdBy
|
||||
} ?: run {
|
||||
holder.clear()
|
||||
holder.itemView.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EventDiffCallback : DiffUtil.ItemCallback<Announcement>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: Announcement, newItem: Announcement): Boolean {
|
||||
return oldItem.uid == newItem.uid
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Announcement, newItem: Announcement): Boolean {
|
||||
return oldItem.getHash() == newItem.getHash()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.sebse.fuplanner2.ui.details_announcements
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.work.workDataOf
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.utils.enqueueOneTimeWork
|
||||
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_ACCOUNT_NAME
|
||||
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_COURSE_ID
|
||||
import de.sebse.fuplanner2.worker.AnnouncementWorker
|
||||
import kotlinx.android.synthetic.main.fragment_refresh_recycler.view.*
|
||||
|
||||
class AnnouncementsFragment : Fragment() {
|
||||
|
||||
private var title: String = ""
|
||||
private val args: AnnouncementsFragmentArgs by navArgs()
|
||||
private val announcementsViewModel: AnnouncementsViewModel by viewModels { AnnouncementsViewModelFactory(args.courseId) }
|
||||
private lateinit var navController: NavController
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val viewManager = LinearLayoutManager(context)
|
||||
val viewAdapter = AnnouncementsAdapter()
|
||||
return inflater.inflate(R.layout.fragment_refresh_recycler, container, false).apply {
|
||||
console.log(findViewById(R.id.recycler_view), recycler_view)
|
||||
recycler_view.apply {
|
||||
setHasFixedSize(true)
|
||||
|
||||
layoutManager = viewManager
|
||||
adapter = viewAdapter
|
||||
}
|
||||
swipe_refresh_layout.setOnRefreshListener {
|
||||
enqueueOneTimeWork<AnnouncementWorker>(context.applicationContext) {
|
||||
it.setInputData(workDataOf(
|
||||
KEY_ACCOUNT_NAME to AppAccounts.getInstance().selectedAccount?.name,
|
||||
KEY_COURSE_ID to args.courseId
|
||||
))
|
||||
}.observe(viewLifecycleOwner, Observer {
|
||||
if (it.state.isFinished)
|
||||
swipe_refresh_layout.isRefreshing = false
|
||||
})
|
||||
}
|
||||
announcementsViewModel.events.observe(viewLifecycleOwner, Observer {
|
||||
viewAdapter.submitList(it)
|
||||
this@AnnouncementsFragment.title = args.title
|
||||
})
|
||||
navController = findNavController()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.sebse.fuplanner2.ui.details_announcements
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import de.sebse.fuplanner2.database.Announcement
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
|
||||
class AnnouncementsViewModelFactory(private val courseId: Long): ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T = AnnouncementsViewModel(courseId) as T
|
||||
}
|
||||
|
||||
class AnnouncementsViewModel(courseId: Long) : ViewModel() {
|
||||
private val factory = AppDatabase.getInstance().announcementDao().getAll1(courseId)
|
||||
val events: LiveData<PagedList<Announcement>> = LivePagedListBuilder(factory, 50).build()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.sebse.fuplanner2.ui.details_description
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.WebSettings
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.ui.details.DetailsViewModel
|
||||
import de.sebse.fuplanner2.ui.details.DetailsViewModelFactory
|
||||
import kotlinx.android.synthetic.main.fragment_description.view.*
|
||||
|
||||
|
||||
class DescriptionFragment : Fragment() {
|
||||
|
||||
private var title: String = ""
|
||||
private val args: DescriptionFragmentArgs by navArgs()
|
||||
private val detailsViewModel: DetailsViewModel by viewModels { DetailsViewModelFactory(args.courseId) }
|
||||
private lateinit var navController: NavController
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
return inflater.inflate(R.layout.fragment_description, container, false).apply {
|
||||
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
description.settings.forceDark = WebSettings.FORCE_DARK_ON
|
||||
}
|
||||
detailsViewModel.course.observe(viewLifecycleOwner, Observer {
|
||||
description.loadDataWithBaseURL("", it.description, "text/html", "UTF-8", "")
|
||||
})
|
||||
navController = findNavController()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.sebse.fuplanner2.ui.details_events
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import de.sebse.fuplanner2.database.Event
|
||||
import de.sebse.fuplanner2.ui.*
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.utils.toDateTimeString
|
||||
|
||||
class EventsAdapter() : PagedListAdapter<Event, ListItemHolder>(EventDiffCallback()) {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListItemHolder {
|
||||
return ListItemHolder.invoke(parent)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ListItemHolder, position: Int) {
|
||||
val event = getItem(position)
|
||||
val actCtx = holder.itemView.context
|
||||
|
||||
event?.let {
|
||||
holder.title.text = it.title
|
||||
holder.subLeft.text = it.startDateTime.toDateTimeString(actCtx)
|
||||
holder.subRight.text = it.location
|
||||
} ?: run {
|
||||
holder.clear()
|
||||
holder.itemView.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EventDiffCallback : DiffUtil.ItemCallback<Event>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: Event, newItem: Event): Boolean {
|
||||
return oldItem.uid == newItem.uid
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Event, newItem: Event): Boolean {
|
||||
return oldItem.getHash() == newItem.getHash()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.sebse.fuplanner2.ui.details_events
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.work.workDataOf
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.utils.enqueueOneTimeWork
|
||||
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_ACCOUNT_NAME
|
||||
import de.sebse.fuplanner2.worker.AbstractAccountWorker.Companion.KEY_COURSE_ID
|
||||
import de.sebse.fuplanner2.worker.CourseWorker
|
||||
import de.sebse.fuplanner2.worker.EventWorker
|
||||
import kotlinx.android.synthetic.main.fragment_refresh_recycler.view.*
|
||||
|
||||
|
||||
class EventsFragment : Fragment() {
|
||||
|
||||
private var title: String = ""
|
||||
private val args: EventsFragmentArgs by navArgs()
|
||||
private val eventsViewModel: EventsViewModel by viewModels { EventsViewModelFactory(args.courseId) }
|
||||
private lateinit var navController: NavController
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val viewManager = LinearLayoutManager(context)
|
||||
val viewAdapter = EventsAdapter()
|
||||
return inflater.inflate(R.layout.fragment_refresh_recycler, container, false).apply {
|
||||
recycler_view.apply {
|
||||
setHasFixedSize(true)
|
||||
|
||||
layoutManager = viewManager
|
||||
adapter = viewAdapter
|
||||
}
|
||||
swipe_refresh_layout.setOnRefreshListener {
|
||||
enqueueOneTimeWork<EventWorker>(context.applicationContext) {
|
||||
it.setInputData(workDataOf(
|
||||
KEY_ACCOUNT_NAME to AppAccounts.getInstance().selectedAccount?.name,
|
||||
KEY_COURSE_ID to args.courseId
|
||||
))
|
||||
}.observe(viewLifecycleOwner, Observer {
|
||||
if (it.state.isFinished)
|
||||
swipe_refresh_layout.isRefreshing = false
|
||||
})
|
||||
}
|
||||
eventsViewModel.events.observe(viewLifecycleOwner, Observer {
|
||||
viewAdapter.submitList(it)
|
||||
this@EventsFragment.title = args.title
|
||||
})
|
||||
navController = findNavController()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.sebse.fuplanner2.ui.details_events
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Event
|
||||
|
||||
|
||||
class EventsViewModelFactory(private val courseId: Long): ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T = EventsViewModel(courseId) as T
|
||||
}
|
||||
|
||||
class EventsViewModel(courseId: Long) : ViewModel() {
|
||||
private val factory = AppDatabase.getInstance().eventDao().getAll1(courseId)
|
||||
val events: LiveData<PagedList<Event>> = LivePagedListBuilder(factory, 50).build()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.sebse.fuplanner2.ui.gallery
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import de.sebse.fuplanner2.R
|
||||
|
||||
class GalleryFragment : Fragment() {
|
||||
|
||||
private lateinit var galleryViewModel: GalleryViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
galleryViewModel =
|
||||
ViewModelProviders.of(this).get(GalleryViewModel::class.java)
|
||||
val root = inflater.inflate(R.layout.fragment_canteen, container, false)
|
||||
val textView: TextView = root.findViewById(R.id.text_gallery)
|
||||
galleryViewModel.text.observe(viewLifecycleOwner, Observer {
|
||||
textView.text = it
|
||||
})
|
||||
return root
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.sebse.fuplanner2.ui.gallery
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class GalleryViewModel : ViewModel() {
|
||||
|
||||
private val _text = MutableLiveData<String>().apply {
|
||||
value = "This is gallery Fragment"
|
||||
}
|
||||
val text: LiveData<String> = _text
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package de.sebse.fuplanner2.ui.notification
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.navigation.NavController
|
||||
import androidx.paging.PagedListAdapter
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import com.beust.klaxon.JsonObject
|
||||
import de.sebse.fuplanner2.database.*
|
||||
import de.sebse.fuplanner2.ui.NotificationHolder
|
||||
import de.sebse.fuplanner2.utils.Notifications
|
||||
import de.sebse.fuplanner2.utils.timeAgoString
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NotificationAdapter(val navController: NavController): PagedListAdapter<Notification, NotificationHolder>(NotificationDiffCallback()) {
|
||||
|
||||
val notificationDao = AppDatabase.getInstance().notificationDao()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NotificationHolder {
|
||||
return NotificationHolder.invoke(parent)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: NotificationHolder, position: Int) {
|
||||
val notification = getItem(position)
|
||||
val actCtx = holder.itemView.context
|
||||
|
||||
notification?.getJsonData()?.let { json ->
|
||||
when (notification.channelId) {
|
||||
Notifications.COURSE_UPDATE_CHANNEL_ID -> {
|
||||
val updateType =
|
||||
Notifications.CourseUpdateType.values[json.int(Notifications.COURSE_UPDATE_TYPE_KEY) ?: 0]
|
||||
val entityType =
|
||||
Notifications.CourseUpdateEntity.values[json.int(Notifications.COURSE_UPDATE_ENTITY_KEY) ?: 0]
|
||||
val data = json.obj(Notifications.COURSE_UPDATE_DATA_KEY) ?: JsonObject()
|
||||
val adapterText = when (entityType) {
|
||||
Notifications.CourseUpdateEntity.COURSE -> Course
|
||||
Notifications.CourseUpdateEntity.ANNOUNCEMENT -> Announcement
|
||||
Notifications.CourseUpdateEntity.ASSIGNMENT -> TODO()
|
||||
Notifications.CourseUpdateEntity.GRADE -> TODO()
|
||||
Notifications.CourseUpdateEntity.RESOURCE -> TODO()
|
||||
Notifications.CourseUpdateEntity.EVENT -> Event
|
||||
}.adapterText(actCtx, updateType, data)
|
||||
val adapterCallback = when (entityType) {
|
||||
Notifications.CourseUpdateEntity.COURSE -> Course
|
||||
Notifications.CourseUpdateEntity.ANNOUNCEMENT -> Announcement
|
||||
Notifications.CourseUpdateEntity.ASSIGNMENT -> TODO()
|
||||
Notifications.CourseUpdateEntity.GRADE -> TODO()
|
||||
Notifications.CourseUpdateEntity.RESOURCE -> TODO()
|
||||
Notifications.CourseUpdateEntity.EVENT -> Event
|
||||
}.adapterCallback(actCtx, updateType, data, navController)
|
||||
Pair(adapterText, adapterCallback)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}?.let { (adapterText, adapterCallback) ->
|
||||
holder.string.text = adapterText
|
||||
holder.timestamp.text = notification.created.timeAgoString(actCtx)
|
||||
holder.itemView.setOnClickListener {
|
||||
adapterCallback()
|
||||
GlobalScope.launch {
|
||||
notificationDao.setRead(notification.uid!!)
|
||||
}
|
||||
}
|
||||
holder.read.visibility = if (notification.read)
|
||||
View.INVISIBLE
|
||||
else
|
||||
View.VISIBLE
|
||||
|
||||
} ?: run {
|
||||
holder.clear()
|
||||
holder.itemView.setOnClickListener(null)
|
||||
holder.read.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationDiffCallback : DiffUtil.ItemCallback<Notification>() {
|
||||
|
||||
override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean {
|
||||
return oldItem.uid == newItem.uid
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean {
|
||||
return oldItem.data == newItem.data && oldItem.read == newItem.read
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.sebse.fuplanner2.ui.notification
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.*
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import kotlinx.android.synthetic.main.fragment_recycler.view.*
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
class NotificationFragment : Fragment() {
|
||||
|
||||
private lateinit var viewModel: NotificationViewModel
|
||||
private lateinit var navController: NavController
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.notification_menu, menu)
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
navController = findNavController()
|
||||
val viewManager = LinearLayoutManager(context)
|
||||
val viewAdapter = NotificationAdapter(navController)
|
||||
viewModel = ViewModelProvider(this).get(NotificationViewModel::class.java)
|
||||
|
||||
viewModel.text.observe(viewLifecycleOwner, Observer {
|
||||
viewAdapter.submitList(it)
|
||||
})
|
||||
|
||||
return inflater.inflate(R.layout.fragment_recycler, container, false).apply {
|
||||
recycler_view.apply {
|
||||
//setHasFixedSize(true)
|
||||
layoutManager = viewManager
|
||||
adapter = viewAdapter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
val id = item.itemId
|
||||
return when (id) {
|
||||
R.id.read_all -> {
|
||||
GlobalScope.launch {
|
||||
AppDatabase.getInstance().notificationDao().setRead()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.sebse.fuplanner2.ui.notification
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Notification
|
||||
|
||||
class NotificationViewModel : ViewModel() {
|
||||
private val factory = AppDatabase.getInstance().notificationDao().getAllPaged()
|
||||
val text: LiveData<PagedList<Notification>> = LivePagedListBuilder(factory, 50).build()
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package de.sebse.fuplanner2.ui.schedule
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.view.*
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.alamkanak.weekview.WeekView
|
||||
import com.alamkanak.weekview.WeekViewDisplayable
|
||||
import com.alamkanak.weekview.WeekViewEvent
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.database.Event
|
||||
import de.sebse.fuplanner2.utils.getHtmlSpannedString
|
||||
import de.sebse.fuplanner2.utils.toTimeString
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.random.Random
|
||||
|
||||
class ScheduleFragment : Fragment() {
|
||||
|
||||
private val scheduleViewModel: ScheduleViewModel by viewModels { ScheduleViewModelFactory() }
|
||||
private var currentDate = Calendar.getInstance()
|
||||
private var weekView: WeekView<ContextEvent>? = null
|
||||
|
||||
init {
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
inflater.inflate(R.menu.schedule_menu, menu)
|
||||
super.onCreateOptionsMenu(menu, inflater)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
scheduleViewModel.eventModel.observe(viewLifecycleOwner, Observer {
|
||||
weekView?.submit(it.map { ContextEvent(requireContext(), it) })
|
||||
weekView?.notifyDataSetChanged()
|
||||
})
|
||||
|
||||
val navController = findNavController()
|
||||
var alertCourse: Course? = null
|
||||
val alertDialog = AlertDialog.Builder(requireContext())
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.close) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
}
|
||||
.setPositiveButton(R.string.view_course) { _, _ ->
|
||||
alertCourse?.let { course ->
|
||||
navController.navigate(ScheduleFragmentDirections.actionNavScheduleToCourseDetails(course.uid!!, course.title))
|
||||
}
|
||||
}
|
||||
.create()
|
||||
|
||||
|
||||
return inflater.inflate(R.layout.fragment_schedule, container, false).apply {
|
||||
weekView = findViewById<WeekView<ContextEvent>>(R.id.weekView).apply {
|
||||
setOnLoadMoreListener { start, end ->
|
||||
scheduleViewModel.loadBetween(start.timeInMillis, end.timeInMillis)
|
||||
}
|
||||
|
||||
setOnRangeChangeListener { firstVisibleDate, _ ->
|
||||
currentDate = firstVisibleDate.clone() as Calendar
|
||||
}
|
||||
|
||||
setOnEventClickListener { data, _ ->
|
||||
alertDialog.run {
|
||||
GlobalScope.launch {
|
||||
val course: Course? = withContext(Dispatchers.IO) {
|
||||
AppDatabase.getInstance().courseDao().getCourseById2(data.event.courseId)
|
||||
}
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val cs: CharSequence = SpannableStringBuilder().apply {
|
||||
if (course != null) {
|
||||
this.append(context.getHtmlSpannedString(R.string.dialog_course_br, course.title))
|
||||
}
|
||||
if (!data.event.location.isNullOrBlank()) {
|
||||
this.append(context.getHtmlSpannedString(R.string.dialog_location_br, data.event.location))
|
||||
}
|
||||
this.append(context.getHtmlSpannedString(
|
||||
R.string.dialog_time,
|
||||
data.event.startDateTime.toTimeString(context),
|
||||
(data.event.startDateTime + data.event.duration).toTimeString(context)
|
||||
))
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
alertCourse = course
|
||||
setTitle(data.event.title)
|
||||
setMessage(cs)
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val todayDate = Calendar.getInstance()
|
||||
todayDate.firstDayOfWeek = Calendar.SATURDAY
|
||||
todayDate.set(Calendar.DAY_OF_WEEK, Calendar.SATURDAY)
|
||||
todayDate.add(Calendar.DAY_OF_WEEK, 2)
|
||||
goToDate(todayDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
val id = item.itemId
|
||||
return when (id) {
|
||||
R.id.prev_week -> {
|
||||
currentDate.firstDayOfWeek = Calendar.TUESDAY
|
||||
currentDate.set(Calendar.DAY_OF_WEEK, Calendar.TUESDAY)
|
||||
currentDate.add(Calendar.DAY_OF_WEEK, -1)
|
||||
weekView?.goToDate(currentDate)
|
||||
true
|
||||
}
|
||||
R.id.next_week -> {
|
||||
currentDate.firstDayOfWeek = Calendar.MONDAY
|
||||
currentDate.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
|
||||
currentDate.add(Calendar.DAY_OF_WEEK, 7)
|
||||
weekView?.goToDate(currentDate)
|
||||
true
|
||||
}
|
||||
R.id.go_to_today -> {
|
||||
weekView?.goToToday()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ContextEvent(val actCtx: Context, val event: Event): WeekViewDisplayable<ContextEvent> {
|
||||
override fun toWeekViewEvent(): WeekViewEvent<ContextEvent> {
|
||||
// Build the styling of the event, for instance background color and strike-through
|
||||
val style = WeekViewEvent.Style.Builder()
|
||||
.setBackgroundColor(getColor(actCtx, event.courseId))
|
||||
.setTextStrikeThrough(false)
|
||||
.setTextColorResource(R.color.scheduleOtherText)
|
||||
.build()
|
||||
|
||||
// Build the WeekViewEvent via the Builder
|
||||
return WeekViewEvent.Builder(this)
|
||||
.setId(event.getHash().toLong())
|
||||
.setTitle(event.title)
|
||||
.setStartTime(Calendar.getInstance().apply { timeInMillis = event.startDateTime })
|
||||
.setEndTime(Calendar.getInstance().apply { timeInMillis = event.startDateTime + event.duration })
|
||||
.setLocation(event.location ?: "")
|
||||
.setAllDay(false)
|
||||
.setStyle(style)
|
||||
.build()
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun getColor(actCtx: Context, seed: Long): Int {
|
||||
var h = Random(seed).nextInt(0xFFFF)
|
||||
h = h * 360 / 0xffff
|
||||
//int s = 0xff & encodedHash[2];
|
||||
//s = s * 100 / 0xffff;
|
||||
//int v = 0xff & encodedHash[3];
|
||||
//v = v * 100 / 0xffff;
|
||||
|
||||
// range for more beautiful colors
|
||||
h = h / 30 * 30
|
||||
val (s, v) =
|
||||
if (actCtx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES)
|
||||
Pair(100, 20)//Pair(100, 80)
|
||||
else
|
||||
Pair(60, 100)
|
||||
val (r, g, b) = hsvToRgb(h / 360.0, s / 100.0, v / 100.0)
|
||||
return rgbToColorInt(r, g, b)
|
||||
}
|
||||
|
||||
private fun hsvToRgb(
|
||||
hue: Double,
|
||||
saturation: Double,
|
||||
value: Double
|
||||
): Triple<Double, Double, Double> {
|
||||
val h = (hue * 6).toInt()
|
||||
val f = hue * 6 - h
|
||||
val p = value * (1 - saturation)
|
||||
val q = value * (1 - f * saturation)
|
||||
val t = value * (1 - (1 - f) * saturation)
|
||||
return when (h) {
|
||||
0 -> Triple(value, t, p)
|
||||
1 -> Triple(q, value, p)
|
||||
2 -> Triple(p, value, t)
|
||||
3 -> Triple(p, q, value)
|
||||
4 -> Triple(t, p, value)
|
||||
5 -> Triple(value, p, q)
|
||||
else -> Triple(0.0, 0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
private fun rgbToColorInt(
|
||||
r: Double,
|
||||
g: Double,
|
||||
b: Double
|
||||
): Int {
|
||||
return 0xFF000000.toInt() + (r*0xFF).roundToInt() * 0x10000 + (g*0xFF).roundToInt() * 0x100 + (b*0xFF).roundToInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.sebse.fuplanner2.ui.schedule
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.paging.LivePagedListBuilder
|
||||
import androidx.paging.PagedList
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Event
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ScheduleViewModelFactory(): ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T = ScheduleViewModel() as T
|
||||
}
|
||||
|
||||
class ScheduleViewModel() : ViewModel() {
|
||||
private val values: HashMap<Int, Event> = hashMapOf()
|
||||
private val events: MutableLiveData<List<Event>> = MutableLiveData()
|
||||
|
||||
val eventModel: LiveData<List<Event>>
|
||||
get() {
|
||||
return events
|
||||
}
|
||||
|
||||
fun loadBetween(start: Long, end: Long) {
|
||||
GlobalScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val data = AppDatabase.getInstance().eventDao().getAllBetween(start, end)
|
||||
values.putAll(data.map { it.getHash() to it })
|
||||
events.postValue(values.values.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
131
app/src/main/java/de/sebse/fuplanner2/ui/viewholder.kt
Normal file
131
app/src/main/java/de/sebse/fuplanner2/ui/viewholder.kt
Normal file
@@ -0,0 +1,131 @@
|
||||
package de.sebse.fuplanner2.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Button
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import de.sebse.fuplanner2.R
|
||||
|
||||
sealed class CustomHolder(base: View) : RecyclerView.ViewHolder(base) {
|
||||
abstract fun clear()
|
||||
}
|
||||
|
||||
class CaptionHolder(base: View) : CustomHolder(base) {
|
||||
companion object {
|
||||
private const val LAYOUT = R.layout.list_all_caption
|
||||
fun invoke(parent: ViewGroup) = CaptionHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(LAYOUT, parent, false)
|
||||
)
|
||||
}
|
||||
val string: TextView = base.findViewById(R.id.string)
|
||||
|
||||
override fun clear() {
|
||||
string.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationHolder(base: View) : CustomHolder(base) {
|
||||
companion object {
|
||||
private const val LAYOUT = R.layout.list_notification_item
|
||||
fun invoke(parent: ViewGroup) = NotificationHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(LAYOUT, parent, false)
|
||||
)
|
||||
}
|
||||
val string: TextView = base.findViewById(R.id.string)
|
||||
val timestamp: TextView = base.findViewById(R.id.timestamp)
|
||||
val read: ImageView = base.findViewById(R.id.read)
|
||||
|
||||
override fun clear() {
|
||||
string.text = ""
|
||||
timestamp.text = ""
|
||||
read.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
class QuickLinksHolder(base: View) : CustomHolder(base) {
|
||||
companion object {
|
||||
private const val LAYOUT = R.layout.list_courses_quicklinks
|
||||
fun invoke(parent: ViewGroup) = QuickLinksHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(LAYOUT, parent, false)
|
||||
)
|
||||
}
|
||||
val btnDescription: Button = base.findViewById(R.id.btn_description)
|
||||
val btnResources: Button = base.findViewById(R.id.btn_resources)
|
||||
val btnGradebook: Button = base.findViewById(R.id.btn_gradebook)
|
||||
val btnAnnouncements: Button = base.findViewById(R.id.btn_announcements)
|
||||
val btnAssignments: Button = base.findViewById(R.id.btn_assignments)
|
||||
val btnEvents: Button = base.findViewById(R.id.btn_events)
|
||||
|
||||
override fun clear() {
|
||||
btnDescription.setOnClickListener(null)
|
||||
btnResources.setOnClickListener(null)
|
||||
btnGradebook.setOnClickListener(null)
|
||||
btnAnnouncements.setOnClickListener(null)
|
||||
btnAssignments.setOnClickListener(null)
|
||||
btnEvents.setOnClickListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
class MailHolder(base: View) : CustomHolder(base) {
|
||||
companion object {
|
||||
private const val LAYOUT = R.layout.list_all_mails
|
||||
fun invoke(parent: ViewGroup) = MailHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(LAYOUT, parent, false)
|
||||
)
|
||||
}
|
||||
val title: TextView = base.findViewById(R.id.title)
|
||||
val subLeft: TextView = base.findViewById(R.id.sub_left)
|
||||
val icon: ImageView = base.findViewById(R.id.icon)
|
||||
|
||||
override fun clear() {
|
||||
title.text = ""
|
||||
subLeft.text = ""
|
||||
icon.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
|
||||
class ListItemHolder(base: View) : CustomHolder(base) {
|
||||
companion object {
|
||||
private const val LAYOUT = R.layout.list_all_items
|
||||
fun invoke(parent: ViewGroup) = ListItemHolder(
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(LAYOUT, parent, false)
|
||||
)
|
||||
}
|
||||
val title: TextView = base.findViewById(R.id.title)
|
||||
val subLeft: TextView = base.findViewById(R.id.sub_left)
|
||||
val subRight: TextView = base.findViewById(R.id.sub_right)
|
||||
val topRight: TextView = base.findViewById(R.id.top_right)
|
||||
|
||||
override fun clear() {
|
||||
title.text = ""
|
||||
subLeft.text = ""
|
||||
subRight.text = ""
|
||||
topRight.text = ""
|
||||
}
|
||||
}
|
||||
|
||||
object ViewHolderGenerator {
|
||||
enum class HolderType {
|
||||
HEADER, BUTTONS, MAIL, ITEM;
|
||||
companion object {
|
||||
val values: List<HolderType> = values().toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun getHolderByType(parent: ViewGroup, viewType: Int): CustomHolder {
|
||||
return when (HolderType.values[viewType]) {
|
||||
HolderType.HEADER -> CaptionHolder.invoke(parent)
|
||||
HolderType.ITEM -> ListItemHolder.invoke(parent)
|
||||
HolderType.BUTTONS -> QuickLinksHolder.invoke(parent)
|
||||
HolderType.MAIL -> MailHolder.invoke(parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package de.sebse.fuplanner2.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.text.TextUtils
|
||||
|
||||
/**
|
||||
* Created by akshaynandwana on
|
||||
* 08, February, 2019
|
||||
**/
|
||||
/* object CustomTabHelper {
|
||||
private var sPackageNameToUse: String? = null
|
||||
private const val STABLE_PACKAGE = "com.android.chrome"
|
||||
private const val BETA_PACKAGE = "com.chrome.beta"
|
||||
private const val DEV_PACKAGE = "com.chrome.dev"
|
||||
private const val LOCAL_PACKAGE = "com.google.android.apps.chrome"
|
||||
|
||||
fun getPackageNameToUse(context: Context, url: String): String? {
|
||||
|
||||
sPackageNameToUse?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val pm = context.packageManager
|
||||
|
||||
val activityIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
val defaultViewHandlerInfo = pm.resolveActivity(activityIntent, 0)
|
||||
var defaultViewHandlerPackageName: String? = null
|
||||
|
||||
defaultViewHandlerInfo?.let {
|
||||
defaultViewHandlerPackageName = it.activityInfo.packageName
|
||||
}
|
||||
|
||||
val resolvedActivityList = pm.queryIntentActivities(activityIntent, 0)
|
||||
val packagesSupportingCustomTabs = ArrayList<String>()
|
||||
for (info in resolvedActivityList) {
|
||||
val serviceIntent = Intent()
|
||||
serviceIntent.action = CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION
|
||||
serviceIntent.setPackage(info.activityInfo.packageName)
|
||||
|
||||
pm.resolveService(serviceIntent, 0)?.let {
|
||||
packagesSupportingCustomTabs.add(info.activityInfo.packageName)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
packagesSupportingCustomTabs.isEmpty() -> sPackageNameToUse = null
|
||||
packagesSupportingCustomTabs.size == 1 -> sPackageNameToUse = packagesSupportingCustomTabs[0]
|
||||
!TextUtils.isEmpty(defaultViewHandlerPackageName)
|
||||
&& !hasSpecializedHandlerIntents(context, activityIntent)
|
||||
&& packagesSupportingCustomTabs.contains(defaultViewHandlerPackageName) ->
|
||||
sPackageNameToUse = defaultViewHandlerPackageName
|
||||
packagesSupportingCustomTabs.contains(STABLE_PACKAGE) -> sPackageNameToUse = STABLE_PACKAGE
|
||||
packagesSupportingCustomTabs.contains(BETA_PACKAGE) -> sPackageNameToUse = BETA_PACKAGE
|
||||
packagesSupportingCustomTabs.contains(DEV_PACKAGE) -> sPackageNameToUse = DEV_PACKAGE
|
||||
packagesSupportingCustomTabs.contains(LOCAL_PACKAGE) -> sPackageNameToUse = LOCAL_PACKAGE
|
||||
}
|
||||
return sPackageNameToUse
|
||||
}
|
||||
|
||||
private fun hasSpecializedHandlerIntents(context: Context, intent: Intent): Boolean {
|
||||
try {
|
||||
val pm = context.packageManager
|
||||
val handlers = pm.queryIntentActivities(
|
||||
intent,
|
||||
PackageManager.GET_RESOLVED_FILTER)
|
||||
if (handlers.size == 0) {
|
||||
return false
|
||||
}
|
||||
for (resolveInfo in handlers) {
|
||||
val filter = resolveInfo.filter ?: continue
|
||||
if (filter.countDataAuthorities() == 0 || filter.countDataPaths() == 0) continue
|
||||
if (resolveInfo.activityInfo == null) continue
|
||||
return true
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
}
|
||||
return false
|
||||
}
|
||||
} */
|
||||
203
app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt
Normal file
203
app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt
Normal file
@@ -0,0 +1,203 @@
|
||||
package de.sebse.fuplanner2.utils
|
||||
|
||||
//import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.navigation.NavController
|
||||
import com.beust.klaxon.JsonObject
|
||||
import de.sebse.fuplanner2.MainActivity
|
||||
import de.sebse.fuplanner2.R
|
||||
import de.sebse.fuplanner2.database.*
|
||||
|
||||
object Notifications {
|
||||
|
||||
private const val COURSE_UPDATE_NOTIFICATION_ID: Int = 456987
|
||||
internal const val COURSE_UPDATE_CHANNEL_ID: String = "COURSE_UPDATE_CHANNEL_ID"
|
||||
internal const val COURSE_UPDATE_TYPE_KEY: String = "COURSE_UPDATE_TYPE_KEY"
|
||||
internal const val COURSE_UPDATE_DATA_KEY: String = "COURSE_UPDATE_DATA_KEY"
|
||||
internal const val COURSE_UPDATE_ENTITY_KEY: String = "COURSE_UPDATE_ENTITY_KEY"
|
||||
|
||||
enum class CourseUpdateType {
|
||||
REMOVED, UPDATED, ADDED;
|
||||
companion object {
|
||||
val values: List<CourseUpdateType> = values().toList()
|
||||
}
|
||||
}
|
||||
|
||||
enum class CourseUpdateEntity {
|
||||
COURSE, ANNOUNCEMENT, ASSIGNMENT, GRADE, RESOURCE, EVENT;
|
||||
companion object {
|
||||
val values: List<CourseUpdateEntity> = values().toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun init(actCtx: Context) {
|
||||
createNotificationChannel(actCtx, COURSE_UPDATE_CHANNEL_ID, R.string.channel_name, R.string.channel_description)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(
|
||||
actCtx: Context,
|
||||
channelId: String,
|
||||
@StringRes channelName: Int,
|
||||
@StringRes channelDescription: Int
|
||||
) {
|
||||
// Create the NotificationChannel, but only on API 26+ because
|
||||
// the NotificationChannel class is new and not in the support library
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name = actCtx.getString(channelName)
|
||||
val descriptionText = actCtx.getString(channelDescription)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val channel = NotificationChannel(channelId, name, importance).apply {
|
||||
description = descriptionText
|
||||
}
|
||||
// Register the channel with the system
|
||||
(actCtx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun <T: Updatable> courseUpdates(updates: UpdateResult<T>, database: AppDatabase, actCtx: Context) {
|
||||
val newNotifications = listOf(
|
||||
CourseUpdateType.REMOVED to updates.removed,
|
||||
CourseUpdateType.ADDED to updates.added,
|
||||
CourseUpdateType.UPDATED to updates.updated
|
||||
).map { pair ->
|
||||
pair.second.map {
|
||||
Notification(
|
||||
null,
|
||||
System.currentTimeMillis(),
|
||||
false,
|
||||
COURSE_UPDATE_CHANNEL_ID,
|
||||
JsonObject().apply {
|
||||
put(COURSE_UPDATE_TYPE_KEY, pair.first.ordinal)
|
||||
put(COURSE_UPDATE_DATA_KEY, it.getData())
|
||||
put(COURSE_UPDATE_ENTITY_KEY, it.getNotificationEntityType().ordinal)
|
||||
}.toJsonString()
|
||||
)
|
||||
}
|
||||
}.flatten()
|
||||
database.notificationDao().upsert(newNotifications)
|
||||
if (newNotifications.isEmpty()) return
|
||||
val unread = database.notificationDao().loadAllUnreadByChannelId(COURSE_UPDATE_CHANNEL_ID)
|
||||
val pendingIntent: PendingIntent = Intent(actCtx, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
putExtra(MainActivity.EXTRA_OPEN_NOTIFICATIONS, true)
|
||||
}.let {
|
||||
PendingIntent.getActivity(actCtx, 0, it, 0)
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(actCtx, COURSE_UPDATE_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_logo_mono)
|
||||
.setContentTitle(actCtx.getString(R.string.not_course_update_title))
|
||||
.setContentText(actCtx.getQuantityString(R.plurals.not_course_update_text, unread.size, unread.size))
|
||||
.setStyle(NotificationCompat.InboxStyle().also { inboxStyle ->
|
||||
unread.forEach { not ->
|
||||
not.getJsonData()?.let { json ->
|
||||
val updateType =
|
||||
CourseUpdateType.values[json.int(COURSE_UPDATE_TYPE_KEY) ?: 0]
|
||||
val entityType =
|
||||
CourseUpdateEntity.values[json.int(COURSE_UPDATE_ENTITY_KEY) ?: 0]
|
||||
val data = json.obj(COURSE_UPDATE_DATA_KEY) ?: JsonObject()
|
||||
when (entityType) {
|
||||
CourseUpdateEntity.COURSE -> Course
|
||||
CourseUpdateEntity.ANNOUNCEMENT -> Announcement
|
||||
CourseUpdateEntity.ASSIGNMENT -> TODO()
|
||||
CourseUpdateEntity.GRADE -> TODO()
|
||||
CourseUpdateEntity.RESOURCE -> TODO()
|
||||
CourseUpdateEntity.EVENT -> Event
|
||||
}.notificationText(actCtx, updateType, data)
|
||||
}?.let { inboxStyle.addLine(it) }
|
||||
}
|
||||
})
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
(actCtx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.notify(actCtx.packageName, COURSE_UPDATE_NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
data class UpdateResult<T: Updatable>(
|
||||
val removed: ArrayList<T> = arrayListOf(),
|
||||
val added: ArrayList<T> = arrayListOf(),
|
||||
val updated: ArrayList<T> = arrayListOf(),
|
||||
val unmodified: ArrayList<T> = arrayListOf()
|
||||
) {
|
||||
fun merge(other: UpdateResult<T>): UpdateResult<T> {
|
||||
this.removed.addAll(other.removed)
|
||||
this.added.addAll(other.added)
|
||||
this.updated.addAll(other.updated)
|
||||
this.unmodified.addAll(other.unmodified)
|
||||
return this
|
||||
}
|
||||
|
||||
val values: List<T>
|
||||
get() {
|
||||
return this.added + this.updated + this.unmodified
|
||||
}
|
||||
}
|
||||
|
||||
fun <S: Updatable> mergeUpdatable(source: UpdateResult<Updatable>, plus: UpdateResult<S>): UpdateResult<Updatable> {
|
||||
source.removed.addAll(plus.removed)
|
||||
source.added.addAll(plus.added)
|
||||
source.updated.addAll(plus.updated)
|
||||
source.unmodified.addAll(plus.unmodified)
|
||||
return source
|
||||
}
|
||||
|
||||
interface Updatable {
|
||||
fun getIdentifier(): String
|
||||
fun getHash(): Int
|
||||
fun getData(): JsonObject
|
||||
fun getNotificationEntityType(): Notifications.CourseUpdateEntity
|
||||
}
|
||||
|
||||
interface UpdatableCompanion {
|
||||
fun notificationText(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject
|
||||
): CharSequence?
|
||||
fun adapterText(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject
|
||||
): CharSequence?
|
||||
fun adapterCallback(
|
||||
actCtx: Context,
|
||||
type: Notifications.CourseUpdateType,
|
||||
data: JsonObject,
|
||||
navController: NavController
|
||||
): () -> Unit
|
||||
}
|
||||
|
||||
fun <T: Updatable> updateResultOf(old: List<T>, new: List<T>): UpdateResult<T> {
|
||||
val newIds = new
|
||||
.map { it.getIdentifier() to Pair(it.getHash(), it) }
|
||||
.toMap()
|
||||
val oldIds = old.map { it.getIdentifier() }.toSet()
|
||||
val removed: ArrayList<T> = arrayListOf()
|
||||
val added: ArrayList<T> = arrayListOf()
|
||||
val updated: ArrayList<T> = arrayListOf()
|
||||
val unmodified: ArrayList<T> = arrayListOf()
|
||||
old.forEach {
|
||||
newIds[it.getIdentifier()]?.run {
|
||||
if (it.getHash() == first) {
|
||||
unmodified.add(second)
|
||||
} else {
|
||||
updated.add(second)
|
||||
}
|
||||
} ?: removed.add(it)
|
||||
}
|
||||
newIds.forEach {
|
||||
if (!oldIds.contains(it.key))
|
||||
added.add(it.value.second)
|
||||
}
|
||||
return UpdateResult(removed, added, updated, unmodified)
|
||||
}
|
||||
|
||||
181
app/src/main/java/de/sebse/fuplanner2/utils/utils.kt
Normal file
181
app/src/main/java/de/sebse/fuplanner2/utils/utils.kt
Normal file
@@ -0,0 +1,181 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package de.sebse.fuplanner2.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.text.Html
|
||||
import android.text.Spanned
|
||||
import android.text.format.DateFormat
|
||||
import android.util.Log
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.work.*
|
||||
import de.sebse.fuplanner2.R
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import java.text.ParseException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
|
||||
object console {
|
||||
fun log(vararg obj: Any?) {
|
||||
largeLog({ tag, msg -> Log.d(tag, msg) }, "KTConsole", obj.joinToString(" "))
|
||||
}
|
||||
|
||||
fun warn(vararg obj: Any?) {
|
||||
largeLog({ tag, msg -> Log.w(tag, msg) }, "KTConsole", obj.joinToString(" "))
|
||||
}
|
||||
|
||||
fun error(vararg obj: Any?) {
|
||||
largeLog({ tag, msg -> Log.e(tag, msg) }, "KTConsole", obj.joinToString(" "))
|
||||
}
|
||||
|
||||
private fun largeLog(
|
||||
method: (String?, String) -> Unit,
|
||||
tag: String,
|
||||
content: String
|
||||
) {
|
||||
if (content.length > 4000) {
|
||||
method(tag, content.substring(0, 4000))
|
||||
largeLog(method, tag, content.substring(4000))
|
||||
} else {
|
||||
method(tag, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object xml {
|
||||
fun decode(xml: String): String {
|
||||
return if (Build.VERSION.SDK_INT >= 24) {
|
||||
Html.fromHtml(xml , Html.FROM_HTML_MODE_LEGACY).toString()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Html.fromHtml(xml).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T: ListenableWorker> enqueueOneTimeWork(appCtx: Context, workBuilder: (OneTimeWorkRequest.Builder) -> OneTimeWorkRequest.Builder): LiveData<WorkInfo> {
|
||||
val work = workBuilder(OneTimeWorkRequestBuilder<T>()).build()
|
||||
val workManager = WorkManager.getInstance(appCtx)
|
||||
workManager.enqueue(work)
|
||||
return workManager.getWorkInfoByIdLiveData(work.id)
|
||||
}
|
||||
|
||||
fun <A, B>List<A>.pmap(f: suspend (A) -> B): List<B> = runBlocking {
|
||||
map { async { f(it) } }.map { it.await() }
|
||||
}
|
||||
|
||||
fun hashCodeOf(vararg elements: Any?): Int {
|
||||
var code = 0
|
||||
elements.forEach { code = code*31 + (it?.hashCode() ?: 0) }
|
||||
return code
|
||||
}
|
||||
|
||||
inline fun <reified T> cast(any: Any?) : T? = any as? T?
|
||||
|
||||
|
||||
fun String.toHtmlSpan(): Spanned = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
Html.fromHtml(this, Html.FROM_HTML_MODE_LEGACY)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Html.fromHtml(this)
|
||||
}
|
||||
|
||||
fun Context.getHtmlSpannedString(@StringRes id: Int): Spanned = getString(id).toHtmlSpan()
|
||||
|
||||
fun Context.getHtmlSpannedString(@StringRes id: Int, vararg formatArgs: Any?): Spanned = getString(id, *formatArgs).toHtmlSpan()
|
||||
|
||||
fun Context.getQuantityString(@PluralsRes id: Int, quantity: Int): String = resources.getQuantityString(id, quantity)
|
||||
|
||||
fun Context.getQuantityString(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any?): String = resources.getQuantityString(id, quantity, *formatArgs)
|
||||
|
||||
fun Context.getQuantityHtmlSpannedString(@PluralsRes id: Int, quantity: Int): Spanned = getQuantityString(id, quantity).toHtmlSpan()
|
||||
|
||||
fun Context.getQuantityHtmlSpannedString(@PluralsRes id: Int, quantity: Int, vararg formatArgs: Any?): Spanned = getQuantityString(id, quantity, *formatArgs).toHtmlSpan()
|
||||
|
||||
|
||||
|
||||
|
||||
fun Long.timeAgoString(actCtx: Context): String {
|
||||
val SECOND_MILLIS = 1000
|
||||
val MINUTE_MILLIS = 60 * SECOND_MILLIS
|
||||
val HOUR_MILLIS = 60 * MINUTE_MILLIS
|
||||
val DAY_MILLIS = 24 * HOUR_MILLIS
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
if (this > now || this <= 0) {
|
||||
return ""
|
||||
}
|
||||
val diff = now - this
|
||||
return when {
|
||||
diff < MINUTE_MILLIS -> actCtx.getString(R.string.time_just_now)
|
||||
diff < 60 * MINUTE_MILLIS -> actCtx.getQuantityString(R.plurals.time_minutes_ago, (diff / MINUTE_MILLIS).toInt(), diff / MINUTE_MILLIS)
|
||||
diff < 24 * HOUR_MILLIS -> actCtx.getQuantityString(R.plurals.time_hours_ago, (diff / HOUR_MILLIS).toInt(), diff / HOUR_MILLIS)
|
||||
else -> actCtx.getQuantityString(R.plurals.time_days_ago, (diff / DAY_MILLIS).toInt(), diff / DAY_MILLIS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun Long.toDateTimeString(context: Context): String? {
|
||||
return toDateString(context, "dd.MM.yy hh:mm")
|
||||
}
|
||||
|
||||
fun Long.toTimeString(context: Context): String? {
|
||||
return toDateString(context, "hh:mm")
|
||||
}
|
||||
|
||||
fun Long.toDateString(context: Context): String? {
|
||||
return toDateString(context, "dd.MM.yy")
|
||||
}
|
||||
|
||||
fun Long.toDateString(
|
||||
context: Context,
|
||||
skeleton: String
|
||||
): String? {
|
||||
return this.toDateString(context, Locale.getDefault(), skeleton)
|
||||
}
|
||||
|
||||
@SuppressLint("SimpleDateFormat")
|
||||
fun Long.toDateString(
|
||||
context: Context,
|
||||
locale: Locale,
|
||||
skeleton: String
|
||||
): String? {
|
||||
val userSkeleton = if (DateFormat.is24HourFormat(context))
|
||||
skeleton.replace("h".toRegex(), "H")
|
||||
else
|
||||
skeleton.replace("H".toRegex(), "h")
|
||||
return getDateFormat(locale, userSkeleton)
|
||||
?.let { SimpleDateFormat(it) }
|
||||
?.format(Date(this))
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
|
||||
fun getDateFormat(locale: Locale, skeleton: String): String? {
|
||||
return DateFormat.getBestDateTimePattern(locale, skeleton)
|
||||
}
|
||||
|
||||
fun dateEquals(a: Long, b: Long): Boolean {
|
||||
return a / 86400000 == b / 86400000
|
||||
}
|
||||
|
||||
fun String.dateStringToLong(format: String): Long? {
|
||||
return this.dateStringToLong(format, Locale.getDefault())
|
||||
}
|
||||
|
||||
fun String.dateStringToLong(format: String, locale: Locale): Long? {
|
||||
return try {
|
||||
SimpleDateFormat(format, locale)
|
||||
.parse(this)
|
||||
?.time
|
||||
} catch (e: ParseException) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.sebse.fuplanner2.whiteboard
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.VolleyError
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
import de.sebse.fuplanner2.database.*
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.utils.UpdateResult
|
||||
import de.sebse.fuplanner2.utils.pmap
|
||||
import de.sebse.fuplanner2.utils.updateResultOf
|
||||
import de.sebse.fuplanner2.worker.FetchResourceErrorType
|
||||
import de.sebse.fuplanner2.worker.FetchResourceException
|
||||
|
||||
|
||||
suspend fun Whiteboard.getAnnouncements(ctx: Context, database: AppDatabase, user: User, course: Course): UpdateResult<Announcement> {
|
||||
if (!isAvailable(user)) return UpdateResult()
|
||||
if (course.moduleType != MODULE_TYPE) return UpdateResult()
|
||||
val courseId = course.uid ?: return UpdateResult()
|
||||
|
||||
val requester = Requester(ctx)
|
||||
val stored = database.announcementDao().getAll2(courseId)
|
||||
|
||||
try {
|
||||
val data = requester.get(
|
||||
FETCH_ANNOUNCEMENT_LIST.format(course.internalId),
|
||||
getCookies(user)
|
||||
)
|
||||
val json = Parser.default().parse(StringBuilder(data.body)) as JsonObject
|
||||
val new = json.array<JsonObject>("announcement_collection")?.pmap { obj ->
|
||||
val id: String = obj.string("announcementId") ?: return@pmap null
|
||||
val title: String = obj.string("title") ?: ""
|
||||
val body: String = obj.string("body") ?: ""
|
||||
val createdOn: Long = obj.long("createdOn") ?: 0
|
||||
val createdBy: String = obj.string("creatorUserId") ?: ""
|
||||
val attachments: List<Attachment> = obj.array<JsonObject>("attachments")?.map attach@{
|
||||
Attachment(
|
||||
it.string("url") ?: return@attach null,
|
||||
it.string("name") ?: return@attach null,
|
||||
it.string("type") ?: return@attach null
|
||||
)
|
||||
}?.filterNotNull() ?: listOf()
|
||||
Announcement(null, courseId, System.currentTimeMillis(), id, title, body, createdOn, createdBy, attachments)
|
||||
}?.filterNotNull() ?: listOf()
|
||||
val result = updateResultOf(stored, new)
|
||||
database.announcementDao().run {
|
||||
delete(result.removed)
|
||||
upsert(result.values)
|
||||
}
|
||||
return result
|
||||
} catch (e: VolleyError) {
|
||||
val statusCode = e.networkResponse.statusCode
|
||||
if (statusCode == 401)
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_AUTHORIZATION, statusCode)
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_NETWORK_ERROR, statusCode)
|
||||
}
|
||||
}
|
||||
127
app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtCourses.kt
Normal file
127
app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtCourses.kt
Normal file
@@ -0,0 +1,127 @@
|
||||
package de.sebse.fuplanner2.whiteboard
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.VolleyError
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
import de.sebse.fuplanner2.auth.FUAuthModule
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.database.Lecturer
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.utils.UpdateResult
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.utils.pmap
|
||||
import de.sebse.fuplanner2.utils.updateResultOf
|
||||
import de.sebse.fuplanner2.worker.FetchResourceErrorType
|
||||
import de.sebse.fuplanner2.worker.FetchResourceException
|
||||
|
||||
|
||||
|
||||
suspend fun Whiteboard.getCourseByLocationRef(stored: List<Course>, requester: Requester, user: User, locationRef: String): Course {
|
||||
val course = stored.find { it.internalId == locationRef }?.apply {
|
||||
if (lastRefreshed > System.currentTimeMillis() - FUAuthModule.REFRESH_FREQUENCY_COURSES_MILLIS)
|
||||
return this
|
||||
}
|
||||
return getCourseByCourse(requester, user, locationRef, course)
|
||||
}
|
||||
|
||||
suspend fun Whiteboard.getCourseByCourse(requester: Requester, user: User, locationRef: String, course: Course?): Course {
|
||||
val jsonSite = requester
|
||||
.get(
|
||||
FETCH_COURSE_DETAILS.format(locationRef),
|
||||
getCookies(user)
|
||||
)
|
||||
.let { Parser.default().parse(StringBuilder(it.body)) as JsonObject }
|
||||
val uid = course?.uid
|
||||
val moduleType = course?.moduleType ?: MODULE_TYPE
|
||||
val (isSummerSemester, year) = jsonSite.obj("props")?.string("term_eid")?.let semester@{termEid ->
|
||||
val type: String? = Regex("^(SS|WS) ").find(termEid)?.groupValues?.getOrNull(1)
|
||||
val year: String? = Regex("^(SS|WS) ([0-9]{2})").find(termEid)?.groupValues?.getOrNull(2)
|
||||
val isSS = type == "SS"
|
||||
val yearInt = if (type != null && year != null) year.toIntOrNull(10) else null
|
||||
return@semester Pair(isSS, yearInt)
|
||||
} ?: Pair(false, null)
|
||||
val lecturers = jsonSite.obj("props")?.string("kvv_lecturers")
|
||||
?.split("#")
|
||||
?.mapNotNull { lecString ->
|
||||
return@mapNotNull Regex("(.*?)\\|(.*?)\\|(.*?)\\|\\|(.*)")
|
||||
.matchEntire(lecString)
|
||||
?.groupValues?.let lecturer@{ match ->
|
||||
return@lecturer Lecturer(match[1], match[2], match[3], match[4] == "true")
|
||||
}
|
||||
} ?: listOf()
|
||||
val lvNumbers = jsonSite
|
||||
.obj("props")?.string("kvv_lvnumbers")
|
||||
?.split(" + ")
|
||||
?.toHashSet() ?: hashSetOf()
|
||||
@Suppress("SpellCheckingInspection")
|
||||
return Course(
|
||||
uid,
|
||||
user.uid!!,
|
||||
System.currentTimeMillis(),
|
||||
isSummerSemester,
|
||||
year,
|
||||
lvNumbers,
|
||||
jsonSite.string("entityTitle") ?: "",
|
||||
jsonSite.obj("props")?.string("kvv_coursetype") ?: "Sonstiges",
|
||||
jsonSite.string("description") ?: "",
|
||||
locationRef,
|
||||
moduleType,
|
||||
lecturers
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun Whiteboard.getCourses(ctx: Context, database: AppDatabase, user: User): UpdateResult<Course> {
|
||||
if (!isAvailable(user)) return UpdateResult()
|
||||
val userId = user.uid ?: return UpdateResult()
|
||||
val requester = Requester(ctx)
|
||||
val stored = database.courseDao().getAllByType(userId, MODULE_TYPE)
|
||||
|
||||
try {
|
||||
val data = requester.get(
|
||||
FETCH_COURSE_MEMBERSHIP,
|
||||
getCookies(user)
|
||||
)
|
||||
val json = Parser.default().parse(StringBuilder(data.body)) as JsonObject
|
||||
val new = json.array<JsonObject>("membership_collection")?.pmap { obj ->
|
||||
val locationRef = obj.string("locationReference") ?: ""
|
||||
getCourseByLocationRef(stored, requester, user, locationRef = locationRef)
|
||||
} ?: listOf()
|
||||
val result = updateResultOf(stored, new)
|
||||
database.courseDao().run {
|
||||
delete(result.removed)
|
||||
upsert(result.values)
|
||||
}
|
||||
return result
|
||||
} catch (e: VolleyError) {
|
||||
val statusCode = e.networkResponse.statusCode
|
||||
if (statusCode == 401)
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_AUTHORIZATION, statusCode)
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_NETWORK_ERROR, statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun Whiteboard.getCourse(ctx: Context, database: AppDatabase, user: User, courseId: Long): UpdateResult<Course> {
|
||||
if (!isAvailable(user)) return UpdateResult()
|
||||
val requester = Requester(ctx)
|
||||
|
||||
try {
|
||||
database.courseDao().run {
|
||||
val old = getCourseById2(courseId)
|
||||
if (old.moduleType == MODULE_TYPE) {
|
||||
getCourseByCourse(requester, user, old.internalId, old).also {
|
||||
upsert(it)
|
||||
return updateResultOf(listOf(old), listOf(it))
|
||||
}
|
||||
}
|
||||
return UpdateResult()
|
||||
}
|
||||
} catch (e: VolleyError) {
|
||||
val statusCode = e.networkResponse.statusCode
|
||||
if (statusCode == 401)
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_AUTHORIZATION, statusCode)
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_NETWORK_ERROR, statusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package de.sebse.fuplanner2.whiteboard
|
||||
|
||||
import android.content.Context
|
||||
import com.android.volley.VolleyError
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.database.Event
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.utils.UpdateResult
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.utils.pmap
|
||||
import de.sebse.fuplanner2.utils.updateResultOf
|
||||
import de.sebse.fuplanner2.worker.FetchResourceErrorType
|
||||
import de.sebse.fuplanner2.worker.FetchResourceException
|
||||
|
||||
|
||||
suspend fun Whiteboard.getEvents(ctx: Context, database: AppDatabase, user: User, course: Course): UpdateResult<Event> {
|
||||
if (!isAvailable(user)) return UpdateResult()
|
||||
if (course.moduleType != MODULE_TYPE) return UpdateResult()
|
||||
val courseId = course.uid ?: return UpdateResult()
|
||||
|
||||
val requester = Requester(ctx)
|
||||
val stored = database.eventDao().getAll2(courseId)
|
||||
|
||||
try {
|
||||
val data = requester.get(
|
||||
FETCH_EVENT_LIST.format(course.internalId),
|
||||
getCookies(user)
|
||||
)
|
||||
val json = Parser.default().parse(StringBuilder(data.body)) as JsonObject
|
||||
val new = json.array<JsonObject>("calendar_collection")?.pmap { obj ->
|
||||
val type: String = obj.string("type") ?: "Event"
|
||||
val title: String = obj.string("title") ?: "Event"
|
||||
val duration: Long = obj.long("duration") ?: return@pmap null
|
||||
val firstTime: Long = obj.obj("firstTime")?.long("time") ?: return@pmap null
|
||||
val location: String? = obj.string("location")
|
||||
Event(null, courseId, System.currentTimeMillis(), title, duration, firstTime, location, type)
|
||||
}?.filterNotNull() ?: listOf()
|
||||
val result = updateResultOf(stored, new)
|
||||
database.eventDao().run {
|
||||
delete(result.removed)
|
||||
upsert(result.values)
|
||||
}
|
||||
return result
|
||||
} catch (e: VolleyError) {
|
||||
val statusCode = e.networkResponse.statusCode
|
||||
if (statusCode == 401)
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_AUTHORIZATION, statusCode)
|
||||
throw FetchResourceException(FetchResourceErrorType.ERR_NETWORK_ERROR, statusCode)
|
||||
}
|
||||
}
|
||||
112
app/src/main/java/de/sebse/fuplanner2/whiteboard/Whiteboard.kt
Normal file
112
app/src/main/java/de/sebse/fuplanner2/whiteboard/Whiteboard.kt
Normal file
@@ -0,0 +1,112 @@
|
||||
package de.sebse.fuplanner2.whiteboard
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import com.android.volley.VolleyError
|
||||
import com.beust.klaxon.JsonObject
|
||||
import com.beust.klaxon.Parser
|
||||
import de.sebse.fuplanner2.auth.FUAuthModule
|
||||
import de.sebse.fuplanner2.auth.SamlReponse
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.network.NetData
|
||||
import de.sebse.fuplanner2.network.Requester
|
||||
import de.sebse.fuplanner2.network.tools.invalidResponse
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
|
||||
object Whiteboard: FUAuthModule() {
|
||||
private const val LOGIN_URL = "https://mycampus.imp.fu-berlin.de/sakai-login-tool/container"
|
||||
private const val TEST_URL = "https://mycampus.imp.fu-berlin.de/direct/user/%s.json"
|
||||
private const val RESTORE_ON_REDIRECT_TO = "/portal"
|
||||
private const val REMOVE_COOKIE_ON_REDIRECT_TO = "/sakai-login-tool/container"
|
||||
internal const val MODULE_TYPE = 2
|
||||
|
||||
internal const val FETCH_COURSE_MEMBERSHIP = "https://mycampus.imp.fu-berlin.de/direct/membership.json?_validateSession="
|
||||
internal const val FETCH_COURSE_DETAILS = "https://mycampus.imp.fu-berlin.de/direct%s.json?_validateSession="
|
||||
|
||||
internal const val FETCH_EVENT_LIST = "https://mycampus.imp.fu-berlin.de/direct/calendar%s.json?detailed=true&_validateSession="
|
||||
|
||||
internal const val FETCH_ANNOUNCEMENT_LIST = "https://mycampus.imp.fu-berlin.de/direct/announcement%s.json?n=999999&d=999999999&_validateSession="
|
||||
|
||||
override suspend fun isAvailable(ctx: Context, name: String): Boolean {
|
||||
val requester = Requester(ctx)
|
||||
try {
|
||||
requester.get(String.format(TEST_URL, Uri.encode(name)), null)
|
||||
} catch (e: VolleyError) {
|
||||
return e.networkResponse.statusCode == 403
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun login(ctx: Context, name: String, password: String, user: User) {
|
||||
val requester = Requester(ctx)
|
||||
val request = requester.get(LOGIN_URL, cookies = getCookies(user, shib = true))
|
||||
val samlUri = request.headers["Location"]
|
||||
?: throw invalidResponse(102100, "Location header not set!")
|
||||
if (samlUri == RESTORE_ON_REDIRECT_TO) {
|
||||
updateCookies(user, request)
|
||||
return
|
||||
}
|
||||
if (samlUri == REMOVE_COOKIE_ON_REDIRECT_TO) {
|
||||
if (delCookies(user))
|
||||
login(ctx, name, password, user)
|
||||
return
|
||||
}
|
||||
val samlResponse: SamlReponse = doSaml(ctx, samlUri, name, password, user)
|
||||
// Shib-Session-Cookie
|
||||
var response = requester.post(samlResponse.uri, cookies = getCookies(user), data = hashMapOf(
|
||||
"RelayState" to samlResponse.relayState,
|
||||
"SAMLResponse" to samlResponse.samlResponse
|
||||
))
|
||||
updateCookies(user, response)
|
||||
// Finish BB & Start Session
|
||||
response = requester.get(
|
||||
response.networkResponse.headers["Location"] ?: throw invalidResponse(102101, "No Location header to finish Blackboard"),
|
||||
getCookies(user, shib = true)
|
||||
)
|
||||
|
||||
updateCookies(user, response)
|
||||
|
||||
response = requester.get(String.format(TEST_URL, Uri.encode(name)), getCookies(user))
|
||||
(Parser.default().parse(StringBuilder(response.body)) as JsonObject).run {
|
||||
user.wbInfo.id = string("id") ?: user.wbInfo.id
|
||||
user.email = string("email") ?: user.email
|
||||
user.matNumber = obj("props")?.string("zedat:matrikelnr")?.toIntOrNull() ?: user.matNumber
|
||||
user.firstName = string("firstName") ?: user.firstName
|
||||
user.lastName = string("lastName") ?: user.lastName
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCookies(user: User, response: NetData) {
|
||||
val setCookies = parseCookies(response.networkResponse.allHeaders)
|
||||
setCookies["JSESSIONID"]?.let {
|
||||
user.cookies.wbJsessionId = it
|
||||
}
|
||||
setCookies
|
||||
.filter{ (key, _) -> key.startsWith("_shibsession_") }
|
||||
.forEach { (key, value) ->
|
||||
user.cookies.wbShibKey = key
|
||||
user.cookies.wbShibValue = value
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
|
||||
internal fun getCookies(user: User, shib: Boolean = false): HashMap<String, String>? {
|
||||
val cookies = user.cookies.wbJsessionId?.let { key -> hashMapOf("JSESSIONID" to key) } ?: hashMapOf()
|
||||
if (shib && user.cookies.wbShibValue != null) {
|
||||
user.cookies.wbShibKey?.let { cookies[it] = user.cookies.wbShibValue ?: "" }
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
private fun delCookies(user: User): Boolean {
|
||||
val isSuccessful = user.cookies.wbShibKey != null
|
||||
user.cookies.wbJsessionId = null
|
||||
user.cookies.wbShibKey = null
|
||||
user.cookies.wbShibValue = null
|
||||
return isSuccessful
|
||||
}
|
||||
|
||||
internal fun isAvailable(user: User): Boolean {
|
||||
return user.cookies.wbJsessionId != null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.sebse.fuplanner2.whiteboard
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
class WhiteboardInfo {
|
||||
var id: String? = null
|
||||
|
||||
constructor() { }
|
||||
|
||||
constructor(json: String) {
|
||||
val obj = JSONObject(json)
|
||||
id = obj.opt("id") as String?
|
||||
}
|
||||
|
||||
fun update(user: WhiteboardInfo) {
|
||||
id = user.id?: id
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
val obj = JSONObject()
|
||||
obj.put("id", id)
|
||||
return obj.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package de.sebse.fuplanner2.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.User
|
||||
|
||||
abstract class AbstractAccountWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
const val KEY_ACCOUNT_NAME = AccountManager.KEY_ACCOUNT_NAME
|
||||
const val KEY_COURSE_ID = "KEY_COURSE_ID"
|
||||
const val OUT_ERROR_CODE = "OUT_CODE"
|
||||
}
|
||||
|
||||
enum class ErrorCodes {
|
||||
ERR_NO_ACCOUNT_NAME, ERR_TOO_MANY_RETRIES, ERR_INVALID_PASSWORD, ERR_NETWORK_ERROR
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
if (runAttemptCount > 2) {
|
||||
return Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_TOO_MANY_RETRIES.ordinal))
|
||||
}
|
||||
val accountName = inputData.getString(KEY_ACCOUNT_NAME)
|
||||
val accounts = AppAccounts.getInstance()
|
||||
val account = accounts.getAccount(accountName)
|
||||
?: return Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_NO_ACCOUNT_NAME.ordinal))
|
||||
|
||||
val database = AppDatabase.getInstance()
|
||||
database.userDao().findByUsername(account.name)?.let {
|
||||
try {
|
||||
val success = doActualWork(database, account, it)
|
||||
return Result.success(success)
|
||||
} catch (e: FetchResourceException) {
|
||||
if (e.type == FetchResourceErrorType.ERR_AUTHORIZATION)
|
||||
return Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_INVALID_PASSWORD.ordinal))
|
||||
if (e.type == FetchResourceErrorType.ERR_NETWORK_ERROR && e.statusCode >= 500)
|
||||
return Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_NETWORK_ERROR.ordinal))
|
||||
return Result.retry()
|
||||
}
|
||||
}
|
||||
return Result.success(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_NO_ACCOUNT_NAME.ordinal))
|
||||
}
|
||||
|
||||
@Throws(FetchResourceException::class)
|
||||
abstract suspend fun doActualWork(database: AppDatabase, account: Account, user: User): Data
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.sebse.fuplanner2.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkerParameters
|
||||
import de.sebse.fuplanner2.blackboard.Blackboard
|
||||
import de.sebse.fuplanner2.blackboard.getAnnouncements
|
||||
import de.sebse.fuplanner2.database.*
|
||||
import de.sebse.fuplanner2.utils.Notifications
|
||||
import de.sebse.fuplanner2.utils.UpdateResult
|
||||
import de.sebse.fuplanner2.whiteboard.Whiteboard
|
||||
import de.sebse.fuplanner2.whiteboard.getAnnouncements
|
||||
|
||||
class AnnouncementWorker(context: Context, params: WorkerParameters) : AbstractAccountWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
suspend fun work(appCtx: Context, database: AppDatabase, user: User, course: Course): UpdateResult<Announcement> {
|
||||
return Whiteboard.getAnnouncements(appCtx, database, user, course)
|
||||
.merge(Blackboard.getAnnouncements(appCtx, database, user, course))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doActualWork(database: AppDatabase, account: Account, user: User): Data {
|
||||
inputData.getLong(KEY_COURSE_ID, -1)
|
||||
.let { if (it == -1L) null else it }
|
||||
?.let {
|
||||
database.courseDao().getCourseById2(it)
|
||||
}
|
||||
?.let {
|
||||
Notifications.courseUpdates(work(applicationContext, database, user, it), database, applicationContext)
|
||||
}
|
||||
return inputData
|
||||
}
|
||||
}
|
||||
47
app/src/main/java/de/sebse/fuplanner2/worker/CourseWorker.kt
Normal file
47
app/src/main/java/de/sebse/fuplanner2/worker/CourseWorker.kt
Normal file
@@ -0,0 +1,47 @@
|
||||
package de.sebse.fuplanner2.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkerParameters
|
||||
import de.sebse.fuplanner2.blackboard.Blackboard
|
||||
import de.sebse.fuplanner2.blackboard.getCourse
|
||||
import de.sebse.fuplanner2.blackboard.getCourses
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.utils.Notifications
|
||||
import de.sebse.fuplanner2.utils.UpdateResult
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.whiteboard.Whiteboard
|
||||
import de.sebse.fuplanner2.whiteboard.getCourse
|
||||
import de.sebse.fuplanner2.whiteboard.getCourses
|
||||
|
||||
class CourseWorker(context: Context, params: WorkerParameters) : AbstractAccountWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
suspend fun work(appCtx: Context, database: AppDatabase, user: User, courseId: Long? = null): UpdateResult<Course> {
|
||||
return if (courseId != null) {
|
||||
Whiteboard.getCourse(appCtx, database, user, courseId)
|
||||
.merge(Blackboard.getCourse(appCtx, database, user, courseId))
|
||||
} else {
|
||||
Whiteboard.getCourses(appCtx, database, user)
|
||||
.merge(Blackboard.getCourses(appCtx, database, user))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doActualWork(database: AppDatabase, account: Account, user: User): Data {
|
||||
val courseId = inputData.getLong(KEY_COURSE_ID, -1)
|
||||
.let { if (it == -1L) null else it }
|
||||
val updates = work(applicationContext, database, user, courseId)
|
||||
val latestSemester = database.courseDao().getLatestSemesterName()
|
||||
updates.added.forEach {
|
||||
if (it.isSummerSemester == latestSemester.semester && it.year == latestSemester.year) {
|
||||
EventWorker.work(applicationContext, database, user, it)
|
||||
}
|
||||
}
|
||||
Notifications.courseUpdates(updates, database, applicationContext)
|
||||
return inputData
|
||||
}
|
||||
}
|
||||
43
app/src/main/java/de/sebse/fuplanner2/worker/EventWorker.kt
Normal file
43
app/src/main/java/de/sebse/fuplanner2/worker/EventWorker.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package de.sebse.fuplanner2.worker
|
||||
|
||||
import android.accounts.Account
|
||||
import android.content.Context
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkerParameters
|
||||
import de.sebse.fuplanner2.blackboard.Blackboard
|
||||
import de.sebse.fuplanner2.blackboard.getCourse
|
||||
import de.sebse.fuplanner2.blackboard.getCourses
|
||||
import de.sebse.fuplanner2.blackboard.getEvents
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.database.Event
|
||||
import de.sebse.fuplanner2.database.User
|
||||
import de.sebse.fuplanner2.utils.Notifications
|
||||
import de.sebse.fuplanner2.utils.UpdateResult
|
||||
import de.sebse.fuplanner2.utils.console
|
||||
import de.sebse.fuplanner2.whiteboard.Whiteboard
|
||||
import de.sebse.fuplanner2.whiteboard.getCourse
|
||||
import de.sebse.fuplanner2.whiteboard.getCourses
|
||||
import de.sebse.fuplanner2.whiteboard.getEvents
|
||||
|
||||
class EventWorker(context: Context, params: WorkerParameters) : AbstractAccountWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
suspend fun work(appCtx: Context, database: AppDatabase, user: User, course: Course): UpdateResult<Event> {
|
||||
return Whiteboard.getEvents(appCtx, database, user, course)
|
||||
.merge(Blackboard.getEvents(appCtx, database, user, course))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doActualWork(database: AppDatabase, account: Account, user: User): Data {
|
||||
inputData.getLong(KEY_COURSE_ID, -1)
|
||||
.let { if (it == -1L) null else it }
|
||||
?.let {
|
||||
database.courseDao().getCourseById2(it)
|
||||
}
|
||||
?.let {
|
||||
Notifications.courseUpdates(work(applicationContext, database, user, it), database, applicationContext)
|
||||
}
|
||||
return inputData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.sebse.fuplanner2.worker
|
||||
|
||||
import java.lang.Exception
|
||||
|
||||
class FetchResourceException(val type: FetchResourceErrorType, val statusCode: Int = 0): Exception() {
|
||||
|
||||
}
|
||||
|
||||
enum class FetchResourceErrorType {
|
||||
ERR_AUTHORIZATION, ERR_NETWORK_ERROR
|
||||
}
|
||||
74
app/src/main/java/de/sebse/fuplanner2/worker/SyncWorker.kt
Normal file
74
app/src/main/java/de/sebse/fuplanner2/worker/SyncWorker.kt
Normal file
@@ -0,0 +1,74 @@
|
||||
package de.sebse.fuplanner2.worker
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.work.*
|
||||
import de.sebse.fuplanner2.auth.AppAccounts
|
||||
import de.sebse.fuplanner2.database.AppDatabase
|
||||
import de.sebse.fuplanner2.database.Course
|
||||
import de.sebse.fuplanner2.utils.Notifications
|
||||
import de.sebse.fuplanner2.utils.Updatable
|
||||
import de.sebse.fuplanner2.utils.UpdateResult
|
||||
import de.sebse.fuplanner2.utils.mergeUpdatable
|
||||
|
||||
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
const val KEY_ACCOUNT_NAME = AccountManager.KEY_ACCOUNT_NAME
|
||||
const val OUT_ERROR_CODE = "OUT_CODE"
|
||||
}
|
||||
|
||||
enum class ErrorCodes {
|
||||
ERR_NO_ACCOUNT_NAME, ERR_TOO_MANY_RETRIES, ERR_INVALID_PASSWORD, ERR_NETWORK_ERROR
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
if (runAttemptCount > 2) {
|
||||
return Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_TOO_MANY_RETRIES.ordinal))
|
||||
}
|
||||
val accountName = inputData.getString(KEY_ACCOUNT_NAME)
|
||||
val accounts = AppAccounts.getInstance()
|
||||
val account = accounts.getAccount(accountName)
|
||||
?: return Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_NO_ACCOUNT_NAME.ordinal))
|
||||
|
||||
when (accounts.refreshSuspended(account)) {
|
||||
AppAccounts.RefreshResults.UNSPECIFIED_ERROR,
|
||||
AppAccounts.RefreshResults.NETWORK_ERROR -> {
|
||||
return Result.retry()
|
||||
}
|
||||
AppAccounts.RefreshResults.SUCCESS -> {
|
||||
val database = AppDatabase.getInstance()
|
||||
database.userDao().findByUsername(account.name)?.also {
|
||||
try {
|
||||
val updates: UpdateResult<Course> = CourseWorker.work(applicationContext, database, it)
|
||||
val courseUpdates = updates.updated
|
||||
val courseCreations = updates.added
|
||||
val notifications = mergeUpdatable(UpdateResult(), updates)
|
||||
courseUpdates.forEach { course ->
|
||||
mergeUpdatable(notifications, EventWorker.work(applicationContext, database, it, course))
|
||||
mergeUpdatable(notifications, AnnouncementWorker.work(applicationContext, database, it, course))
|
||||
}
|
||||
val latestSemester = database.courseDao().getLatestSemesterName()
|
||||
courseCreations.forEach { course ->
|
||||
if (course.isSummerSemester == latestSemester.semester && course.year == latestSemester.year) {
|
||||
EventWorker.work(applicationContext, database, it, course)
|
||||
}
|
||||
}
|
||||
Notifications.courseUpdates(notifications, database, applicationContext)
|
||||
return@doWork Result.success(workDataOf(KEY_ACCOUNT_NAME to accountName))
|
||||
} catch (e: FetchResourceException) {
|
||||
if (e.type == FetchResourceErrorType.ERR_AUTHORIZATION)
|
||||
return@doWork Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_INVALID_PASSWORD.ordinal))
|
||||
if (e.type == FetchResourceErrorType.ERR_NETWORK_ERROR && e.statusCode >= 500)
|
||||
return@doWork Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_NETWORK_ERROR.ordinal))
|
||||
return@doWork Result.retry()
|
||||
}
|
||||
}
|
||||
return Result.failure(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_NO_ACCOUNT_NAME.ordinal))
|
||||
}
|
||||
AppAccounts.RefreshResults.INVALID_PASSWORD -> {
|
||||
return Result.success(workDataOf(OUT_ERROR_CODE to ErrorCodes.ERR_INVALID_PASSWORD.ordinal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
app/src/main/res/anim/slide_in_left.xml
Normal file
6
app/src/main/res/anim/slide_in_left.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shareInterpolator="false" >
|
||||
<translate android:duration="300" android:fromXDelta="-100%" android:toXDelta="0%"/>
|
||||
<alpha android:duration="300" android:fromAlpha="0.0" android:toAlpha="1.0" />
|
||||
</set>
|
||||
6
app/src/main/res/anim/slide_in_right.xml
Normal file
6
app/src/main/res/anim/slide_in_right.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shareInterpolator="false" >
|
||||
<translate android:duration="300" android:fromXDelta="100%" android:toXDelta="0%" />
|
||||
<alpha android:duration="300" android:fromAlpha="0.0" android:toAlpha="1.0" />
|
||||
</set>
|
||||
6
app/src/main/res/anim/slide_out_left.xml
Normal file
6
app/src/main/res/anim/slide_out_left.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shareInterpolator="false" >
|
||||
<translate android:duration="300" android:fromXDelta="0%" android:toXDelta="-100%"/>
|
||||
<alpha android:duration="300" android:fromAlpha="1.0" android:toAlpha="0.0" />
|
||||
</set>
|
||||
6
app/src/main/res/anim/slide_out_right.xml
Normal file
6
app/src/main/res/anim/slide_out_right.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shareInterpolator="false" >
|
||||
<translate android:duration="300" android:fromXDelta="0%" android:toXDelta="100%"/>
|
||||
<alpha android:duration="300" android:fromAlpha="1.0" android:toAlpha="0.0" />
|
||||
</set>
|
||||
BIN
app/src/main/res/drawable-night/logo_campus.png
Normal file
BIN
app/src/main/res/drawable-night/logo_campus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M15.41,16.09l-4.58,-4.59 4.58,-4.59L14,5.5l-6,6 6,6z"/>
|
||||
</vector>
|
||||
10
app/src/main/res/drawable/ic_keyboard_arrow_right.xml
Normal file
10
app/src/main/res/drawable/ic_keyboard_arrow_right.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0"
|
||||
android:tint="@color/colorAccent">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8.59,16.34l4.58,-4.59 -4.58,-4.59L10,5.75l6,6 -6,6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M8.59,16.34l4.58,-4.59 -4.58,-4.59L10,5.75l6,6 -6,6z"/>
|
||||
</vector>
|
||||
11
app/src/main/res/drawable/ic_logo_mono.xml
Normal file
11
app/src/main/res/drawable/ic_logo_mono.xml
Normal file
File diff suppressed because one or more lines are too long
10
app/src/main/res/drawable/ic_mail.xml
Normal file
10
app/src/main/res/drawable/ic_mail.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="40dp"
|
||||
android:height="40dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0"
|
||||
android:tint="@color/colorAccent">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M20,4L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM20,8l-8,5 -8,-5L4,6l8,5 8,-5v2z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_menu_canteen.xml
Normal file
9
app/src/main/res/drawable/ic_menu_canteen.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8.1,13.34l2.83,-2.83L3.91,3.5c-1.56,1.56 -1.56,4.09 0,5.66l4.19,4.18zM14.88,11.53c1.53,0.71 3.68,0.21 5.27,-1.38 1.91,-1.91 2.28,-4.65 0.81,-6.12 -1.46,-1.46 -4.2,-1.1 -6.12,0.81 -1.59,1.59 -2.09,3.74 -1.38,5.27L3.7,19.87l1.41,1.41L12,14.41l6.88,6.88 1.41,-1.41L13.41,13l1.47,-1.47z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_menu_courses.xml
Normal file
9
app/src/main/res/drawable/ic_menu_courses.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M4,8h4L8,4L4,4v4zM10,20h4v-4h-4v4zM4,20h4v-4L4,16v4zM4,14h4v-4L4,10v4zM10,14h4v-4h-4v4zM16,4v4h4L20,4h-4zM10,8h4L14,4h-4v4zM16,14h4v-4h-4v4zM16,20h4v-4h-4v4z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_menu_event.xml
Normal file
9
app/src/main/res/drawable/ic_menu_event.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17,12h-5v5h5v-5zM16,1v2L8,3L8,1L6,1v2L5,3c-1.11,0 -1.99,0.9 -1.99,2L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2h-1L18,1h-2zM19,19L5,19L5,8h14v11z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/ic_menu_notifications.xml
Normal file
9
app/src/main/res/drawable/ic_menu_notifications.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user