commit 3b14696b5dbd9b92bd7f841f78e0dff85c1cc3d1 Author: Sebastian Seedorf Date: Sun Dec 6 19:59:55 2020 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ea113c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle +/local.properties +/.idea/* +/.idea - PC/* +.DS_Store +/build +/captures +.externalNativeBuild +app/release/* diff --git a/FUPlanner 2.iml b/FUPlanner 2.iml new file mode 100644 index 0000000..9c4a891 --- /dev/null +++ b/FUPlanner 2.iml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/app.iml b/app/app.iml new file mode 100644 index 0000000..023d1cb --- /dev/null +++ b/app/app.iml @@ -0,0 +1,251 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..4edd8dc --- /dev/null +++ b/app/build.gradle @@ -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' +} diff --git a/app/demo.kts b/app/demo.kts new file mode 100644 index 0000000..9fc3ed7 --- /dev/null +++ b/app/demo.kts @@ -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) +} \ No newline at end of file diff --git a/app/demo.py b/app/demo.py new file mode 100644 index 0000000..bf094c4 --- /dev/null +++ b/app/demo.py @@ -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) diff --git a/app/demo.ws.kts b/app/demo.ws.kts new file mode 100644 index 0000000..9fdf56e --- /dev/null +++ b/app/demo.ws.kts @@ -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) \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 diff --git a/app/src/androidTest/java/de/sebse/fuplanner2/ExampleInstrumentedTest.kt b/app/src/androidTest/java/de/sebse/fuplanner2/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..81e4365 --- /dev/null +++ b/app/src/androidTest/java/de/sebse/fuplanner2/ExampleInstrumentedTest.kt @@ -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) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3a63c98 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..3251481 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/de/sebse/fuplanner2/CustomApplication.kt b/app/src/main/java/de/sebse/fuplanner2/CustomApplication.kt new file mode 100644 index 0000000..3fd0886 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/CustomApplication.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/MainActivity.kt b/app/src/main/java/de/sebse/fuplanner2/MainActivity.kt new file mode 100644 index 0000000..e8b6f02 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/MainActivity.kt @@ -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(R.id.nav_header_title).text = getString(R.string.full_name, it?.firstName, it?.lastName) + findViewById(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(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 = MutableLiveData().apply { + value = null + } + val notificationCnt: LiveData = database.notificationDao().getUnreadRowCount() + val latestSemester: LiveData> = database.courseDao().getLatestSemester() + + fun updateSelectedUser(account: Account) { + GlobalScope.launch { + user.postValue(database.userDao().findByUsername(account.name)) + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt b/app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt new file mode 100644 index 0000000..22870f6 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/StartupActivity.kt @@ -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 + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/AccountAuthenticatorAppCompatActivity.kt b/app/src/main/java/de/sebse/fuplanner2/auth/AccountAuthenticatorAppCompatActivity.kt new file mode 100644 index 0000000..add6c3d --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/AccountAuthenticatorAppCompatActivity.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/AppAccounts.kt b/app/src/main/java/de/sebse/fuplanner2/auth/AppAccounts.kt new file mode 100644 index 0000000..a083eca --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/AppAccounts.kt @@ -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 + 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(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.") + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/FUAuthModule.kt b/app/src/main/java/de/sebse/fuplanner2/auth/FUAuthModule.kt new file mode 100644 index 0000000..d89f7c0 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/FUAuthModule.kt @@ -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
): HashMap { + val result: HashMap = 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? { + return user.cookies.idpJsessionId?.let { key -> hashMapOf("JSESSIONID" to key) } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountActivity.kt b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountActivity.kt new file mode 100644 index 0000000..48f17f7 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountActivity.kt @@ -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(R.id.user) as EditText).text.toString() + val passWd = + (findViewById(R.id.password) as EditText).text.toString() + findViewById(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(R.id.user).text.clear() + findViewById(R.id.user).setText("seedorf96", TextView.BufferType.EDITABLE) + findViewById(R.id.password).text.clear() + findViewById(R.id.password).setText("m&gcwBaT@", TextView.BufferType.EDITABLE) + findViewById(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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountAuthenticator.kt b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountAuthenticator.kt new file mode 100644 index 0000000..7d43267 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountAuthenticator.kt @@ -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?, + 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 + ): Bundle { + val result = Bundle() + result.putBoolean(KEY_BOOLEAN_RESULT, false) + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountConstants.kt b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountConstants.kt new file mode 100644 index 0000000..1285957 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountConstants.kt @@ -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" +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountHelper.kt b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountHelper.kt new file mode 100644 index 0000000..c8bb86a --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountHelper.kt @@ -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) + } + } + +} diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountService.kt b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountService.kt new file mode 100644 index 0000000..f3a03f0 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/FuplannerAccountService.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/SamlReponse.kt b/app/src/main/java/de/sebse/fuplanner2/auth/SamlReponse.kt new file mode 100644 index 0000000..b7ad168 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/SamlReponse.kt @@ -0,0 +1,3 @@ +package de.sebse.fuplanner2.auth + +data class SamlReponse(val uri: String, val relayState: String, val samlResponse: String) diff --git a/app/src/main/java/de/sebse/fuplanner2/auth/UserCookies.kt b/app/src/main/java/de/sebse/fuplanner2/auth/UserCookies.kt new file mode 100644 index 0000000..21c7346 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/auth/UserCookies.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/blackboard/Blackboard.kt b/app/src/main/java/de/sebse/fuplanner2/blackboard/Blackboard.kt new file mode 100644 index 0000000..fb4d0fb --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/blackboard/Blackboard.kt @@ -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("result") + ?.find { entry -> entry["userName"] == name } + ?.string("id") + + (Parser.default().parse(StringBuilder(response.body)) as JsonObject) + .array("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? { + 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?), + nextPage: ((JsonObject) -> String?) + ): Array { + var data: Array = 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/blackboard/BlackboardInfo.kt b/app/src/main/java/de/sebse/fuplanner2/blackboard/BlackboardInfo.kt new file mode 100644 index 0000000..65c7c06 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/blackboard/BlackboardInfo.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtAnnouncements.kt b/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtAnnouncements.kt new file mode 100644 index 0000000..ce4be21 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtAnnouncements.kt @@ -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 { + 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("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 = 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtCourses.kt b/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtCourses.kt new file mode 100644 index 0000000..e8f71e2 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtCourses.kt @@ -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, requester: Requester, user: User, blockedLvNumbers: Set, 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, 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 { + + 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 { + + 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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtEvents.kt b/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtEvents.kt new file mode 100644 index 0000000..bc4aff8 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/blackboard/ExtEvents.kt @@ -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 { + 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("") + .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("
([^<]*?)
") + .find(it.value) + ?.groups + ?.get(1) + ?.value + ?.trim() + ?: course.title + val location = Regex("
[^~]*?
") + .findAll(it.value) + .map { + if (it.value.contains("Räume:")) + Regex("([^~]*?)

") + .findAll(it.value) + .map { it.groups.get(1)?.value?.trim() } + .filterNotNull() + .toList() + else + listOf() + } + .flatten() + .joinToString(separator = ", ") + val type = Regex("([^<]*)") + .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) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/database/Announcement.kt b/app/src/main/java/de/sebse/fuplanner2/database/Announcement.kt new file mode 100644 index 0000000..d018d9c --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/Announcement.kt @@ -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 +): 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)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/database/AnnouncementDao.kt b/app/src/main/java/de/sebse/fuplanner2/database/AnnouncementDao.kt new file mode 100644 index 0000000..fae3638 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/AnnouncementDao.kt @@ -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 + + @Query("SELECT * FROM announcement WHERE courseId = :courseId ORDER BY createdOn ASC") + fun getAll2(courseId: Long): List + + @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) { + announcements.forEach { announcement -> + upsert(announcement) + } + } + + @Delete + fun delete(announcement: Announcement) + + @Delete + fun delete(announcements: List) +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/database/AppDatabase.kt b/app/src/main/java/de/sebse/fuplanner2/database/AppDatabase.kt new file mode 100644 index 0000000..59996ed --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/AppDatabase.kt @@ -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() + + 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 { + return mIsDatabaseCreated + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/database/Attachment.kt b/app/src/main/java/de/sebse/fuplanner2/database/Attachment.kt new file mode 100644 index 0000000..ea449f8 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/Attachment.kt @@ -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(json) + } catch (e: KlaxonException) { + null + } + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/database/Cache.kt b/app/src/main/java/de/sebse/fuplanner2/database/Cache.kt new file mode 100644 index 0000000..9c320ea --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/Cache.kt @@ -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 + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/database/CacheDao.kt b/app/src/main/java/de/sebse/fuplanner2/database/CacheDao.kt new file mode 100644 index 0000000..1b05bd3 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/CacheDao.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/database/Converters.kt b/app/src/main/java/de/sebse/fuplanner2/database/Converters.kt new file mode 100644 index 0000000..855b6cc --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/Converters.kt @@ -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? { + return value?.joinToString(separator = delimiter) + } + @TypeConverter + fun toStringSet(value: String?): HashSet? { + return value?.split(delimiter)?.toHashSet() + } + + + + @TypeConverter + fun fromLecturerList(value: List?): String? { + return value?.joinToString(separator = delimiter) { it.toJsonString() } + } + @TypeConverter + fun toLecturerList(value: String?): List? { + return value?.split(delimiter)?.mapNotNull { Lecturer.fromString(it) } + } + + + + @TypeConverter + fun fromAttachmentList(value: List?): String? { + return value?.joinToString(separator = delimiter) { it.toJsonString() } + } + @TypeConverter + fun toAttachmentList(value: String?): List? { + return value?.split(delimiter)?.mapNotNull { Attachment.fromString(it) } + } + + +} diff --git a/app/src/main/java/de/sebse/fuplanner2/database/Course.kt b/app/src/main/java/de/sebse/fuplanner2/database/Course.kt new file mode 100644 index 0000000..1859224 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/Course.kt @@ -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, + val title: String, + val type: String, + val description: String, + val internalId: String, + val moduleType: Int, + val lecturers: List +): Comparable, 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)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/database/CourseDao.kt b/app/src/main/java/de/sebse/fuplanner2/database/CourseDao.kt new file mode 100644 index 0000000..a8dc86e --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/CourseDao.kt @@ -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> + + @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> + + @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 + + @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 + + @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) { + courses.forEach { course -> + upsert(course) + } + } + + @Delete + fun delete(course: Course) + + @Delete + fun delete(courses: List) +} + +data class Semester(val year: Int, val semester: Boolean) \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/database/Event.kt b/app/src/main/java/de/sebse/fuplanner2/database/Event.kt new file mode 100644 index 0000000..cb8b80b --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/Event.kt @@ -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)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/database/EventDao.kt b/app/src/main/java/de/sebse/fuplanner2/database/EventDao.kt new file mode 100644 index 0000000..5f0558f --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/EventDao.kt @@ -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 + + @Query("SELECT * FROM event WHERE courseId = :courseId ORDER BY startDateTime ASC") + fun getAll1(courseId: Long): DataSource.Factory + + @Query("SELECT * FROM event WHERE courseId = :courseId ORDER BY startDateTime ASC") + fun getAll2(courseId: Long): List + + @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) { + events.forEach { event -> + upsert(event) + } + } + + @Delete + fun delete(event: Event) + + @Delete + fun delete(events: List) +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/database/Lecturer.kt b/app/src/main/java/de/sebse/fuplanner2/database/Lecturer.kt new file mode 100644 index 0000000..a4b24b5 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/Lecturer.kt @@ -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(json) + } catch (e: KlaxonException) { + null + } + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/database/Notification.kt b/app/src/main/java/de/sebse/fuplanner2/database/Notification.kt new file mode 100644 index 0000000..44c02d7 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/Notification.kt @@ -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 + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/database/NotificationDao.kt b/app/src/main/java/de/sebse/fuplanner2/database/NotificationDao.kt new file mode 100644 index 0000000..963a981 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/NotificationDao.kt @@ -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 + + @Query("SELECT * FROM notification WHERE channelId = :channelId AND read = 0") + fun loadAllUnreadByChannelId(channelId: String): List + + @Query("SELECT COUNT(uid) FROM notification WHERE read = 0") + fun getUnreadRowCount(): LiveData + + @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) { + 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() + +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/database/User.kt b/app/src/main/java/de/sebse/fuplanner2/database/User.kt new file mode 100644 index 0000000..009ab8f --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/User.kt @@ -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 +) diff --git a/app/src/main/java/de/sebse/fuplanner2/database/UserDao.kt b/app/src/main/java/de/sebse/fuplanner2/database/UserDao.kt new file mode 100644 index 0000000..75c0608 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/database/UserDao.kt @@ -0,0 +1,41 @@ +package de.sebse.fuplanner2.database + +import androidx.room.* + +@Dao +interface UserDao { + @Query("SELECT * FROM user") + fun getAll(): List + + @Query("SELECT * FROM user WHERE uid IN (:userIds)") + fun loadAllByIds(userIds: IntArray): List + + @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) + +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/network/CustomRequest.kt b/app/src/main/java/de/sebse/fuplanner2/network/CustomRequest.kt new file mode 100644 index 0000000..83b73e6 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/network/CustomRequest.kt @@ -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 { + /** 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? = null + + private var mCookies: Map? + private var mData: Map? + + constructor( + method: Int, + url: String?, + cookies: Map?, + data: Map?, + listener: Response.Listener?, errorListener: Response.ErrorListener? + ) : super(method, url, errorListener) { + mListener = listener + mCookies = cookies + mData = data + } + + constructor( + method: Int, + url: String?, + cookies: Map?, + listener: Response.Listener?, 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? + 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? { + val parsed: String = parse(response.data, response.headers) ?: "" + return Response.success(NetData(parsed, response), HttpHeaderParser.parseCacheHeaders(response)) + } + + override fun getHeaders(): MutableMap { + var params = + super.getHeaders() + mCookies?.let { cookieMap -> + params = params?.let { HashMap(it) } ?: HashMap() + val newStr = ArrayList() + 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? { + return if (body == null) null else try { + String(body, Charset.forName(HttpHeaderParser.parseCharset(headers))) + } catch (e: UnsupportedEncodingException) { + String(body) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/network/NetData.kt b/app/src/main/java/de/sebse/fuplanner2/network/NetData.kt new file mode 100644 index 0000000..9201f5d --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/network/NetData.kt @@ -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 +} diff --git a/app/src/main/java/de/sebse/fuplanner2/network/Requester.kt b/app/src/main/java/de/sebse/fuplanner2/network/Requester.kt new file mode 100644 index 0000000..571d260 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/network/Requester.kt @@ -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?): 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?): 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?, data: Map?): 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/network/tools.kt b/app/src/main/java/de/sebse/fuplanner2/network/tools.kt new file mode 100644 index 0000000..ec2a667 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/network/tools.kt @@ -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)) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/preferences/AppPreferences.kt b/app/src/main/java/de/sebse/fuplanner2/preferences/AppPreferences.kt new file mode 100644 index 0000000..4fc5d35 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/preferences/AppPreferences.kt @@ -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.") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesAdapter.kt b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesAdapter.kt new file mode 100644 index 0000000..828a0e6 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesAdapter.kt @@ -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() { + + private val positionalData: ArrayList = arrayListOf() + var dataset: List = 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>(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(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 +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesFragment.kt new file mode 100644 index 0000000..637a76b --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesFragment.kt @@ -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() + .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)) + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesViewModel.kt b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesViewModel.kt new file mode 100644 index 0000000..9a4c0e9 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/courses/CoursesViewModel.kt @@ -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> = AppDatabase.getInstance().courseDao().getAll() +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsAdapter.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsAdapter.kt new file mode 100644 index 0000000..c78dcae --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsAdapter.kt @@ -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() { + + enum class HeaderTypes { + BUTTONS, LECTURER, ANNOUNCEMENTS, ASSIGNMENTS, EVENTS; + } + + enum class ButtonTypes { + DESCRIPTION, RESOURCES, GRADEBOOK, ANNOUNCEMENTS, ASSIGNMENTS, EVENTS; + } + + private val positionalData: ArrayList = arrayListOf() + + var lecturers: List = 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(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(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 +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsFragment.kt new file mode 100644 index 0000000..e02da1d --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsFragment.kt @@ -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(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)) + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsViewModel.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsViewModel.kt new file mode 100644 index 0000000..c624c83 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details/DetailsViewModel.kt @@ -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 create(modelClass: Class): T = DetailsViewModel(courseId) as T +} + +class DetailsViewModel(courseId: Long) : ViewModel() { + val course: LiveData = AppDatabase.getInstance().courseDao().getCourseById(courseId) +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsAdapter.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsAdapter.kt new file mode 100644 index 0000000..ee2f375 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsAdapter.kt @@ -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(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() { + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsFragment.kt new file mode 100644 index 0000000..c04318b --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsFragment.kt @@ -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(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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsViewModel.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsViewModel.kt new file mode 100644 index 0000000..68e8a8a --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details_announcements/AnnouncementsViewModel.kt @@ -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 create(modelClass: Class): T = AnnouncementsViewModel(courseId) as T +} + +class AnnouncementsViewModel(courseId: Long) : ViewModel() { + private val factory = AppDatabase.getInstance().announcementDao().getAll1(courseId) + val events: LiveData> = LivePagedListBuilder(factory, 50).build() +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details_description/DescriptionFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details_description/DescriptionFragment.kt new file mode 100644 index 0000000..3968935 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details_description/DescriptionFragment.kt @@ -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() + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsAdapter.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsAdapter.kt new file mode 100644 index 0000000..8eadaca --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsAdapter.kt @@ -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(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() { + + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsFragment.kt new file mode 100644 index 0000000..ae63d50 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsFragment.kt @@ -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(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() + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsViewModel.kt b/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsViewModel.kt new file mode 100644 index 0000000..d21cdfc --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/details_events/EventsViewModel.kt @@ -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 create(modelClass: Class): T = EventsViewModel(courseId) as T +} + +class EventsViewModel(courseId: Long) : ViewModel() { + private val factory = AppDatabase.getInstance().eventDao().getAll1(courseId) + val events: LiveData> = LivePagedListBuilder(factory, 50).build() +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/gallery/GalleryFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/gallery/GalleryFragment.kt new file mode 100644 index 0000000..189e705 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/gallery/GalleryFragment.kt @@ -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 + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/gallery/GalleryViewModel.kt b/app/src/main/java/de/sebse/fuplanner2/ui/gallery/GalleryViewModel.kt new file mode 100644 index 0000000..e293aee --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/gallery/GalleryViewModel.kt @@ -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().apply { + value = "This is gallery Fragment" + } + val text: LiveData = _text +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationAdapter.kt b/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationAdapter.kt new file mode 100644 index 0000000..ba2568b --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationAdapter.kt @@ -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(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() { + + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationFragment.kt new file mode 100644 index 0000000..7ea107f --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationFragment.kt @@ -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) + } + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationViewModel.kt b/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationViewModel.kt new file mode 100644 index 0000000..f987ecf --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/notification/NotificationViewModel.kt @@ -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> = LivePagedListBuilder(factory, 50).build() +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/schedule/ScheduleFragment.kt b/app/src/main/java/de/sebse/fuplanner2/ui/schedule/ScheduleFragment.kt new file mode 100644 index 0000000..b72ddb2 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/schedule/ScheduleFragment.kt @@ -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? = 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>(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 { + override fun toWeekViewEvent(): WeekViewEvent { + // 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 { + 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() + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/schedule/ScheduleViewModel.kt b/app/src/main/java/de/sebse/fuplanner2/ui/schedule/ScheduleViewModel.kt new file mode 100644 index 0000000..3f2bd76 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/schedule/ScheduleViewModel.kt @@ -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 create(modelClass: Class): T = ScheduleViewModel() as T +} + +class ScheduleViewModel() : ViewModel() { + private val values: HashMap = hashMapOf() + private val events: MutableLiveData> = MutableLiveData() + + val eventModel: LiveData> + 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()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/ui/viewholder.kt b/app/src/main/java/de/sebse/fuplanner2/ui/viewholder.kt new file mode 100644 index 0000000..102e959 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/ui/viewholder.kt @@ -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 = 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/utils/CustomTabsHelper.kt b/app/src/main/java/de/sebse/fuplanner2/utils/CustomTabsHelper.kt new file mode 100644 index 0000000..ba8c264 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/utils/CustomTabsHelper.kt @@ -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() + 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 + } +} */ \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt b/app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt new file mode 100644 index 0000000..22d25be --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/utils/notifications.kt @@ -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 = values().toList() + } + } + + enum class CourseUpdateEntity { + COURSE, ANNOUNCEMENT, ASSIGNMENT, GRADE, RESOURCE, EVENT; + companion object { + val values: List = 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 courseUpdates(updates: UpdateResult, 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( + val removed: ArrayList = arrayListOf(), + val added: ArrayList = arrayListOf(), + val updated: ArrayList = arrayListOf(), + val unmodified: ArrayList = arrayListOf() +) { + fun merge(other: UpdateResult): UpdateResult { + 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 + get() { + return this.added + this.updated + this.unmodified + } +} + +fun mergeUpdatable(source: UpdateResult, plus: UpdateResult): UpdateResult { + 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 updateResultOf(old: List, new: List): UpdateResult { + val newIds = new + .map { it.getIdentifier() to Pair(it.getHash(), it) } + .toMap() + val oldIds = old.map { it.getIdentifier() }.toSet() + val removed: ArrayList = arrayListOf() + val added: ArrayList = arrayListOf() + val updated: ArrayList = arrayListOf() + val unmodified: ArrayList = 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) +} + diff --git a/app/src/main/java/de/sebse/fuplanner2/utils/utils.kt b/app/src/main/java/de/sebse/fuplanner2/utils/utils.kt new file mode 100644 index 0000000..2486cad --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/utils/utils.kt @@ -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 enqueueOneTimeWork(appCtx: Context, workBuilder: (OneTimeWorkRequest.Builder) -> OneTimeWorkRequest.Builder): LiveData { + val work = workBuilder(OneTimeWorkRequestBuilder()).build() + val workManager = WorkManager.getInstance(appCtx) + workManager.enqueue(work) + return workManager.getWorkInfoByIdLiveData(work.id) +} + +fun List.pmap(f: suspend (A) -> B): List = 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 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtAnnouncements.kt b/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtAnnouncements.kt new file mode 100644 index 0000000..c5da816 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtAnnouncements.kt @@ -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 { + 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("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 = obj.array("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) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtCourses.kt b/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtCourses.kt new file mode 100644 index 0000000..4cdfc97 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtCourses.kt @@ -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, 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 { + 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("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 { + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtEvents.kt b/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtEvents.kt new file mode 100644 index 0000000..ed3f4af --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/whiteboard/ExtEvents.kt @@ -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 { + 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("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) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/whiteboard/Whiteboard.kt b/app/src/main/java/de/sebse/fuplanner2/whiteboard/Whiteboard.kt new file mode 100644 index 0000000..6db4f5b --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/whiteboard/Whiteboard.kt @@ -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? { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/whiteboard/WhiteboardInfo.kt b/app/src/main/java/de/sebse/fuplanner2/whiteboard/WhiteboardInfo.kt new file mode 100644 index 0000000..d8ddbfc --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/whiteboard/WhiteboardInfo.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/de/sebse/fuplanner2/worker/AbstractAccountWorker.kt b/app/src/main/java/de/sebse/fuplanner2/worker/AbstractAccountWorker.kt new file mode 100644 index 0000000..775094e --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/worker/AbstractAccountWorker.kt @@ -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 +} diff --git a/app/src/main/java/de/sebse/fuplanner2/worker/AnnouncementWorker.kt b/app/src/main/java/de/sebse/fuplanner2/worker/AnnouncementWorker.kt new file mode 100644 index 0000000..7c416a2 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/worker/AnnouncementWorker.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/worker/CourseWorker.kt b/app/src/main/java/de/sebse/fuplanner2/worker/CourseWorker.kt new file mode 100644 index 0000000..998a5e3 --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/worker/CourseWorker.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/worker/EventWorker.kt b/app/src/main/java/de/sebse/fuplanner2/worker/EventWorker.kt new file mode 100644 index 0000000..a5238fa --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/worker/EventWorker.kt @@ -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 { + 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 + } +} diff --git a/app/src/main/java/de/sebse/fuplanner2/worker/FetchResourceException.kt b/app/src/main/java/de/sebse/fuplanner2/worker/FetchResourceException.kt new file mode 100644 index 0000000..521af2a --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/worker/FetchResourceException.kt @@ -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 +} diff --git a/app/src/main/java/de/sebse/fuplanner2/worker/SyncWorker.kt b/app/src/main/java/de/sebse/fuplanner2/worker/SyncWorker.kt new file mode 100644 index 0000000..bbf336b --- /dev/null +++ b/app/src/main/java/de/sebse/fuplanner2/worker/SyncWorker.kt @@ -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 = 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)) + } + } + } +} diff --git a/app/src/main/res/anim/slide_in_left.xml b/app/src/main/res/anim/slide_in_left.xml new file mode 100644 index 0000000..ce098ea --- /dev/null +++ b/app/src/main/res/anim/slide_in_left.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_in_right.xml b/app/src/main/res/anim/slide_in_right.xml new file mode 100644 index 0000000..f5e3d9d --- /dev/null +++ b/app/src/main/res/anim/slide_in_right.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_left.xml b/app/src/main/res/anim/slide_out_left.xml new file mode 100644 index 0000000..57c173e --- /dev/null +++ b/app/src/main/res/anim/slide_out_left.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/anim/slide_out_right.xml b/app/src/main/res/anim/slide_out_right.xml new file mode 100644 index 0000000..5303feb --- /dev/null +++ b/app/src/main/res/anim/slide_out_right.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-night/logo_campus.png b/app/src/main/res/drawable-night/logo_campus.png new file mode 100644 index 0000000..61bded5 Binary files /dev/null and b/app/src/main/res/drawable-night/logo_campus.png differ diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_left_white.xml b/app/src/main/res/drawable/ic_keyboard_arrow_left_white.xml new file mode 100644 index 0000000..876aaca --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_left_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_right.xml b/app/src/main/res/drawable/ic_keyboard_arrow_right.xml new file mode 100644 index 0000000..e98df31 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_right.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_arrow_right_white.xml b/app/src/main/res/drawable/ic_keyboard_arrow_right_white.xml new file mode 100644 index 0000000..e1e9f45 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_arrow_right_white.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_logo_mono.xml b/app/src/main/res/drawable/ic_logo_mono.xml new file mode 100644 index 0000000..b961edc --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_mono.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_mail.xml b/app/src/main/res/drawable/ic_mail.xml new file mode 100644 index 0000000..917fef6 --- /dev/null +++ b/app/src/main/res/drawable/ic_mail.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_canteen.xml b/app/src/main/res/drawable/ic_menu_canteen.xml new file mode 100644 index 0000000..7375ec3 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_canteen.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_courses.xml b/app/src/main/res/drawable/ic_menu_courses.xml new file mode 100644 index 0000000..ff485cf --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_courses.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_event.xml b/app/src/main/res/drawable/ic_menu_event.xml new file mode 100644 index 0000000..22f1bb6 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_event.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_notifications.xml b/app/src/main/res/drawable/ic_menu_notifications.xml new file mode 100644 index 0000000..7009a67 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_notifications.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_menu_notifications_none.xml b/app/src/main/res/drawable/ic_menu_notifications_none.xml new file mode 100644 index 0000000..a4543fd --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_notifications_none.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/logo_campus.png b/app/src/main/res/drawable/logo_campus.png new file mode 100644 index 0000000..d411c38 Binary files /dev/null and b/app/src/main/res/drawable/logo_campus.png differ diff --git a/app/src/main/res/drawable/rounded_corner.xml b/app/src/main/res/drawable/rounded_corner.xml new file mode 100644 index 0000000..f7d44ea --- /dev/null +++ b/app/src/main/res/drawable/rounded_corner.xml @@ -0,0 +1,16 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/side_nav_bar.xml b/app/src/main/res/drawable/side_nav_bar.xml new file mode 100644 index 0000000..62cd0ef --- /dev/null +++ b/app/src/main/res/drawable/side_nav_bar.xml @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml new file mode 100644 index 0000000..7a49197 --- /dev/null +++ b/app/src/main/res/layout/activity_login.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + +