initial commit

This commit is contained in:
Sebastian Seedorf
2020-12-06 19:59:55 +01:00
commit 3b14696b5d
176 changed files with 73741 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.gradle
/local.properties
/.idea/*
/.idea - PC/*
.DS_Store
/build
/captures
.externalNativeBuild
app/release/*

19
FUPlanner 2.iml Normal file
View 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
View File

@@ -0,0 +1 @@
/build

251
app/app.iml Normal file

File diff suppressed because one or more lines are too long

79
app/build.gradle Normal file
View 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
View 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
View 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
View 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
View 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

View File

@@ -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)
}
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View 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)
}
}

View 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))
}
}
}

View 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
}
}

View File

@@ -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()
}
}

View 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.")
}
}
}

View 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) }
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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"
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,3 @@
package de.sebse.fuplanner2.auth
data class SamlReponse(val uri: String, val relayState: String, val samlResponse: String)

View 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()
}
}

View 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
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View 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
)
}
}

View 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)
}
}

View File

@@ -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))
}
}
}
}
}
}
}

View File

@@ -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>)
}

View File

@@ -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
}
}

View 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
}
}
}
}

View 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
}
}
}

View 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)
}

View 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) }
}
}

View 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))
}
}
}
}
}
}
}

View 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)

View 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))
}
}
}
}
}
}
}

View 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>)
}

View 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
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}

View 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
)

View 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)
}

View 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)
}
}
}

View 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
}

View 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)
}
}
}

View 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))
}
}

View File

@@ -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.")
}
}
}

View File

@@ -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
}

View File

@@ -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))
}
}
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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))
}
}
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}
}
}

View 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)
}
}
}

View File

@@ -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
}
} */

View 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)
}

View 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
}
}

View File

@@ -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)
}
}

View 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)
}
}

View File

@@ -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)
}
}

View 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
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View 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
}
}

View 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
}
}

View File

@@ -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
}

View 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))
}
}
}
}

View 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>

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View 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="#FFFFFFFF"
android:pathData="M15.41,16.09l-4.58,-4.59 4.58,-4.59L14,5.5l-6,6 6,6z"/>
</vector>

View 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>

View 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="#FFFFFFFF"
android:pathData="M8.59,16.34l4.58,-4.59 -4.58,-4.59L10,5.75l6,6 -6,6z"/>
</vector>

File diff suppressed because one or more lines are too long

View 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>

View 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>

View 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>

View 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>

View 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