Compare commits

...

25 Commits

Author SHA1 Message Date
1450d63fcb [4.13-beta.2] Update build.gradle, signing and changelog. 2022-10-18 22:06:18 +02:00
2ec06bc39a [UI] Fix timetable lesson height. Add missing timetable settings. 2022-10-18 11:49:46 +02:00
6f12227c2e [4.13-beta.1] Update build.gradle, signing and changelog. 2022-10-17 22:56:22 +02:00
3a91f87ccd Merge pull request #142 from szkolny-eu/feature/usos
[API] Add USOS API support.
2022-10-17 22:52:08 +02:00
52a53334ca [Strings] Add USOS error descriptions. 2022-10-17 22:30:23 +02:00
3ab9602865 [API/Usos] Fix re-logging in after user action. 2022-10-17 16:06:29 +02:00
dc19043f73 [API/Usos] Implement basic error handling. 2022-10-17 12:56:07 +02:00
cf25507850 [API/Usos] Save lecturers as teachers. Add team class. 2022-10-16 18:41:37 +02:00
044cedff99 [Usos] Override lesson colors by activity type. 2022-10-16 18:13:22 +02:00
4de066bf5f [API/Usos] Implement Timetable. 2022-10-16 17:21:36 +02:00
8d174bda01 Merge remote-tracking branch 'origin/develop' into feature/usos 2022-10-16 16:54:44 +02:00
e2fd714070 [UI/Timetable] Show subject-based lesson colors. (#141)
* [UI/Timetable] Add timetable config dialog and implement UI options.

* [UI/Timetable] Fix reloading timetable after changing config.

* [UI/Timetable] Fix calculating lesson range boundaries.

* [UI/Timetable] Add coloring subject names.
2022-10-16 12:47:54 +02:00
8097e8d06d [API/Usos] Add syncing Courses and Terms. 2022-10-16 00:09:51 +02:00
93ccdbdeb7 [API/Login] Make user action handling more universal. 2022-10-15 21:24:30 +02:00
7ded400a30 [API/Usos] Implement first login. 2022-10-15 19:07:12 +02:00
2ff784066e [API/Usos] Implement OAuth authorization flow. 2022-10-14 21:44:58 +02:00
6c96875c83 [API/Login] Allow passing LoginStore params in user action requests. 2022-10-14 19:44:22 +02:00
9f3aaf6e86 [API] Move register platforms to new endpoint. 2022-10-14 14:43:40 +02:00
55369eaa8b [UI/Timetable] Add Timetable settings. (#140)
* [UI/Timetable] Add timetable config dialog and implement UI options.

* [UI/Timetable] Fix reloading timetable after changing config.

* [UI/Timetable] Fix calculating lesson range boundaries.
2022-10-14 00:25:22 +02:00
c983c16907 [UI] Request notifications permission on API >= 33. (#143)
* Added requesting notifications permission on Android 13 devices

* Move permission check to PermissionManager

* Request permission on home screen
2022-10-14 00:24:00 +02:00
c7362bce12 [API/Usos] Add base rest API class. 2022-10-13 21:27:55 +02:00
7935d0f097 [API] Pass parameters to user action required errors. 2022-10-13 11:45:17 +02:00
4b64277948 [API/Usos] Add basic USOS API structure. 2022-10-11 23:23:11 +02:00
132729bbd9 [4.12.1] Update build.gradle, signing and changelog. 2022-09-23 11:32:08 +02:00
6d36ab27d1 [UI/Login] Fix QR scanning icon not showing. 2022-09-23 11:30:23 +02:00
74 changed files with 4245 additions and 207 deletions

View File

@ -13,6 +13,7 @@
<w>synergia</w>
<w>szczodrzyński</w>
<w>szkolny</w>
<w>usos</w>
</words>
</dictionary>
</component>

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- PowerPermission uses minSdk 21, it's safe to override as it is used only in >= 23 -->
<uses-sdk tools:overrideLibrary="com.qifan.powerpermission.coroutines, com.qifan.powerpermission.core" />
@ -157,6 +157,10 @@
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:theme="@style/Base.Theme.AppCompat" />
<activity android:name=".ui.login.oauth.OAuthLoginActivity"
android:configChanges="orientation|keyboardHidden"
android:exported="false"
android:theme="@style/AppTheme.Light" />
<activity android:name=".ui.base.BuildInvalidActivity" android:exported="false" />
<activity android:name=".ui.settings.contributors.ContributorsActivity" android:exported="false" />

View File

@ -1,10 +1,9 @@
<h3>Wersja 4.12, 2022-09-21</h3>
<h3>Wersja 4.13-beta.2, 2022-10-18</h3>
<ul>
<li>Vulcan UONET+: naprawiono działanie systemu wiadomości. @Antoni-Czaplicki</li>
<li>Vulcan UONET+: naprawiono błędy wersji 4.11.9 i starszych.</li>
<li>Poprawiono wyświetlanie lekcji odwołanych na stronie głównej.</li>
<li>Dodano dostęp do Laboratorium na ekranie logowania.</li>
<li>Usunięto obsługę dziennika EduDziennik. [*]</li>
<li>Poprawione powiadomienia na Androidzie 13. @santoni0</li>
<li>Możliwość dostosowania wyświetlania planu lekcji</li>
<li>Opcja kolorowania bloków w planie lekcji</li>
<li><b>USOS</b> - pierwsza wersja obsługi systemu</li>
</ul>
<br>
<br>

View File

@ -9,7 +9,7 @@
/*secret password - removed for source code publication*/
static toys AES_IV[16] = {
0x9f, 0x7e, 0x7a, 0xcd, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
0xde, 0xd4, 0xce, 0xef, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
unsigned char *agony(unsigned int laugh, unsigned char *box, unsigned char *heat);

View File

@ -34,6 +34,7 @@ import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import pl.droidsonroids.gif.GifDrawable
import pl.szczodrzynski.edziennik.data.api.ERROR_REQUIRES_USER_ACTION
import pl.szczodrzynski.edziennik.data.api.ERROR_VULCAN_API_DEPRECATED
import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.api.events.*
@ -69,6 +70,7 @@ import pl.szczodrzynski.edziennik.ui.grades.editor.GradesEditorFragment
import pl.szczodrzynski.edziennik.ui.home.HomeFragment
import pl.szczodrzynski.edziennik.ui.homework.HomeworkFragment
import pl.szczodrzynski.edziennik.ui.login.LoginActivity
import pl.szczodrzynski.edziennik.ui.login.LoginProgressFragment
import pl.szczodrzynski.edziennik.ui.messages.compose.MessagesComposeFragment
import pl.szczodrzynski.edziennik.ui.messages.list.MessagesFragment
import pl.szczodrzynski.edziennik.ui.messages.single.MessageFragment
@ -83,6 +85,7 @@ import pl.szczodrzynski.edziennik.utils.*
import pl.szczodrzynski.edziennik.utils.Utils.d
import pl.szczodrzynski.edziennik.utils.Utils.dpToPx
import pl.szczodrzynski.edziennik.utils.managers.AvailabilityManager.Error.Type
import pl.szczodrzynski.edziennik.utils.managers.UserActionManager
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.NavTarget
import pl.szczodrzynski.navlib.*
@ -853,7 +856,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
@Subscribe(threadMode = ThreadMode.MAIN)
fun onUserActionRequiredEvent(event: UserActionRequiredEvent) {
app.userActionManager.execute(this, event.profileId, event.type)
app.userActionManager.execute(this, event, UserActionManager.UserActionCallback())
}
private fun fragmentToSyncName(currentFragment: Int): Int {
@ -911,11 +914,13 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
false
}
"userActionRequired" -> {
app.userActionManager.execute(
this,
extras.getInt("profileId"),
extras.getInt("type")
val event = UserActionRequiredEvent(
profileId = extras.getInt("profileId"),
type = extras.getEnum<UserActionRequiredEvent.Type>("type") ?: return,
params = extras.getBundle("params") ?: return,
errorText = 0,
)
app.userActionManager.execute(this, event, UserActionManager.UserActionCallback())
true
}
"createManualEvent" -> {

View File

@ -69,4 +69,24 @@ class ProfileConfigUI(private val config: ProfileConfig) {
var messagesGreetingText: String?
get() { mMessagesGreetingText = mMessagesGreetingText ?: config.values["messagesGreetingText"]; return mMessagesGreetingText }
set(value) { config.set("messagesGreetingText", value); mMessagesGreetingText = value }
private var mTimetableShowAttendance: Boolean? = null
var timetableShowAttendance: Boolean
get() { mTimetableShowAttendance = mTimetableShowAttendance ?: config.values.get("timetableShowAttendance", true); return mTimetableShowAttendance ?: true }
set(value) { config.set("timetableShowAttendance", value); mTimetableShowAttendance = value }
private var mTimetableShowEvents: Boolean? = null
var timetableShowEvents: Boolean
get() { mTimetableShowEvents = mTimetableShowEvents ?: config.values.get("timetableShowEvents", true); return mTimetableShowEvents ?: true }
set(value) { config.set("timetableShowEvents", value); mTimetableShowEvents = value }
private var mTimetableTrimHourRange: Boolean? = null
var timetableTrimHourRange: Boolean
get() { mTimetableTrimHourRange = mTimetableTrimHourRange ?: config.values.get("timetableTrimHourRange", false); return mTimetableTrimHourRange ?: false }
set(value) { config.set("timetableTrimHourRange", value); mTimetableTrimHourRange = value }
private var mTimetableColorSubjectName: Boolean? = null
var timetableColorSubjectName: Boolean
get() { mTimetableColorSubjectName = mTimetableColorSubjectName ?: config.values.get("timetableColorSubjectName", false); return mTimetableColorSubjectName ?: false }
set(value) { config.set("timetableColorSubjectName", value); mTimetableColorSubjectName = value }
}

View File

@ -84,19 +84,21 @@ class ApiService : Service() {
runTask()
}
override fun onRequiresUserAction(event: UserActionRequiredEvent) {
app.userActionManager.sendToUser(event)
taskRunning?.cancel()
clearTask()
runTask()
}
override fun onError(apiError: ApiError) {
lastEventTime = System.currentTimeMillis()
d(TAG, "Task $taskRunningId threw an error - $apiError")
apiError.profileId = taskProfileId
if (app.userActionManager.requiresUserAction(apiError)) {
app.userActionManager.sendToUser(apiError)
}
else {
EventBus.getDefault().postSticky(ApiTaskErrorEvent(apiError))
errorList.add(apiError)
apiError.throwable?.printStackTrace()
}
EventBus.getDefault().postSticky(ApiTaskErrorEvent(apiError))
errorList.add(apiError)
apiError.throwable?.printStackTrace()
if (apiError.isCritical) {
taskRunning?.cancel()

View File

@ -59,6 +59,9 @@ const val LIBRUS_SANDBOX_URL = "https://sandbox.librus.pl/index.php?action="
const val LIBRUS_SYNERGIA_HOMEWORK_ATTACHMENT_URL = "https://synergia.librus.pl/homework/downloadFile"
const val LIBRUS_SYNERGIA_MESSAGES_ATTACHMENT_URL = "https://synergia.librus.pl/wiadomosci/pobierz_zalacznik"
const val LIBRUS_PORTAL_RECAPTCHA_KEY = "6Lf48moUAAAAAB9ClhdvHr46gRWR"
const val LIBRUS_PORTAL_RECAPTCHA_REFERER = "https://portal.librus.pl/rodzina/login"
val MOBIDZIENNIK_USER_AGENT = SYSTEM_USER_AGENT
@ -99,3 +102,12 @@ const val PODLASIE_API_VERSION = "1.0.62"
const val PODLASIE_API_URL = "https://cpdklaser.zeto.bialystok.pl/api"
const val PODLASIE_API_USER_ENDPOINT = "/pobierzDaneUcznia"
const val PODLASIE_API_LOGOUT_DEVICES_ENDPOINT = "/wyczyscUrzadzenia"
const val USOS_API_OAUTH_REDIRECT_URL = "szkolny://redirect/usos"
val USOS_API_SCOPES by lazy { listOf(
"offline_access",
"studies",
"grades",
"events",
) }

View File

@ -58,11 +58,7 @@ const val ERROR_INVALID_LOGIN_MODE = 110
const val ERROR_LOGIN_METHOD_NOT_SATISFIED = 111
const val ERROR_NOT_IMPLEMENTED = 112
const val ERROR_FILE_DOWNLOAD = 113
const val ERROR_NO_STUDENTS_IN_ACCOUNT = 115
const val ERROR_CAPTCHA_NEEDED = 3000
const val ERROR_CAPTCHA_LIBRUS_PORTAL = 3001
const val ERROR_REQUIRES_USER_ACTION = 114
const val ERROR_API_PDO_ERROR = 5000
const val ERROR_API_INVALID_CLIENT = 5001
@ -204,6 +200,12 @@ const val ERROR_PODLASIE_API_NO_TOKEN = 630
const val ERROR_PODLASIE_API_OTHER = 631
const val ERROR_PODLASIE_API_DATA_MISSING = 632
const val ERROR_USOS_OAUTH_GOT_DIFFERENT_TOKEN = 702
const val ERROR_USOS_OAUTH_INCOMPLETE_RESPONSE = 703
const val ERROR_USOS_NO_STUDENT_PROGRAMMES = 704
const val ERROR_USOS_API_INCOMPLETE_RESPONSE = 705
const val ERROR_USOS_API_MISSING_RESPONSE = 706
const val ERROR_TEMPLATE_WEB_OTHER = 801
const val EXCEPTION_API_TASK = 900

View File

@ -13,6 +13,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.login.Mobidzie
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.login.PodlasieLoginApi
import pl.szczodrzynski.edziennik.data.api.edziennik.template.login.TemplateLoginApi
import pl.szczodrzynski.edziennik.data.api.edziennik.template.login.TemplateLoginWeb
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.login.UsosLoginApi
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLoginHebe
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLoginWebMain
import pl.szczodrzynski.edziennik.data.api.models.LoginMethod
@ -127,6 +128,15 @@ val podlasieLoginMethods = listOf(
.withRequiredLoginMethod { _, _ -> LOGIN_METHOD_NOT_NEEDED }
)
const val LOGIN_TYPE_USOS = 7
const val LOGIN_MODE_USOS_OAUTH = 0
const val LOGIN_METHOD_USOS_API = 100
val usosLoginMethods = listOf(
LoginMethod(LOGIN_TYPE_USOS, LOGIN_METHOD_USOS_API, UsosLoginApi::class.java)
.withIsPossible { _, _ -> true }
.withRequiredLoginMethod { _, _ -> LOGIN_METHOD_NOT_NEEDED }
)
val templateLoginMethods = listOf(
LoginMethod(LOGIN_TYPE_TEMPLATE, LOGIN_METHOD_TEMPLATE_WEB, TemplateLoginWeb::class.java)
.withIsPossible { _, _ -> true }

View File

@ -12,6 +12,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.librus.Librus
import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.Mobidziennik
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.Podlasie
import pl.szczodrzynski.edziennik.data.api.edziennik.template.Template
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.Usos
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.Vulcan
import pl.szczodrzynski.edziennik.data.api.events.RegisterAvailabilityEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
@ -113,6 +114,7 @@ open class EdziennikTask(override val profileId: Int, val request: Any) : IApiTa
LOGIN_TYPE_VULCAN -> Vulcan(app, profile, loginStore, taskCallback)
LOGIN_TYPE_PODLASIE -> Podlasie(app, profile, loginStore, taskCallback)
LOGIN_TYPE_TEMPLATE -> Template(app, profile, loginStore, taskCallback)
LOGIN_TYPE_USOS -> Usos(app, profile, loginStore, taskCallback)
else -> null
}
if (edziennikInterface == null) {

View File

@ -16,6 +16,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.librus.data.messages.Librus
import pl.szczodrzynski.edziennik.data.api.edziennik.librus.data.synergia.*
import pl.szczodrzynski.edziennik.data.api.edziennik.librus.firstlogin.LibrusFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.librus.login.LibrusLogin
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError
@ -162,6 +163,7 @@ class Librus(val app: App, val profile: Profile?, val loginStore: LoginStore, va
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {
return object : EdziennikCallback {
override fun onCompleted() { callback.onCompleted() }
override fun onRequiresUserAction(event: UserActionRequiredEvent) { callback.onRequiresUserAction(event) }
override fun onProgress(step: Float) { callback.onProgress(step) }
override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) }
override fun onError(apiError: ApiError) {

View File

@ -10,6 +10,7 @@ import im.wangchao.mhttp.callback.TextCallbackHandler
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.librus.DataLibrus
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.utils.Utils.d
@ -148,12 +149,23 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
val error = if (response.code() == 200) null else
json.getJsonArray("errors")?.getString(0)
?: json.getJsonObject("errors")?.entrySet()?.firstOrNull()?.value?.asString
if (error?.contains("robotem") == true || json.getBoolean("captchaRequired") == true) {
data.requireUserAction(
type = UserActionRequiredEvent.Type.RECAPTCHA,
params = Bundle(
"siteKey" to LIBRUS_PORTAL_RECAPTCHA_KEY,
"referer" to LIBRUS_PORTAL_RECAPTCHA_REFERER,
),
errorText = R.string.notification_user_action_required_captcha_librus,
)
return
}
error?.let { code ->
when {
code.contains("Sesja logowania wygasła") -> ERROR_LOGIN_LIBRUS_PORTAL_CSRF_EXPIRED
code.contains("Upewnij się, że nie") -> ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN
// this doesn't work anyway: `errors` is an object with `g-recaptcha-response` set
code.contains("robotem") -> ERROR_CAPTCHA_LIBRUS_PORTAL
code.contains("Podany adres e-mail jest nieprawidłowy.") -> ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN
else -> ERROR_LOGIN_LIBRUS_PORTAL_ACTION_ERROR
}.let { errorCode ->
@ -163,12 +175,6 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
return
}
}
if (json.getBoolean("captchaRequired") == true) {
data.error(ApiError(TAG, ERROR_CAPTCHA_LIBRUS_PORTAL)
.withResponse(response)
.withApiResponse(json))
return
}
authorize(json.getString("redirect", LIBRUS_AUTHORIZE_URL))
}

View File

@ -11,6 +11,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.data.Mobidzien
import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.data.web.*
import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.firstlogin.MobidziennikFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.login.MobidziennikLogin
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError
@ -142,6 +143,7 @@ class Mobidziennik(val app: App, val profile: Profile?, val loginStore: LoginSto
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {
return object : EdziennikCallback {
override fun onCompleted() { callback.onCompleted() }
override fun onRequiresUserAction(event: UserActionRequiredEvent) { callback.onRequiresUserAction(event) }
override fun onProgress(step: Float) { callback.onProgress(step) }
override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) }
override fun onError(apiError: ApiError) {

View File

@ -12,6 +12,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.data.PodlasieData
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.firstlogin.PodlasieFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.login.PodlasieLogin
import pl.szczodrzynski.edziennik.data.api.events.AttachmentGetEvent
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError
@ -142,6 +143,10 @@ class Podlasie(val app: App, val profile: Profile?, val loginStore: LoginStore,
callback.onCompleted()
}
override fun onRequiresUserAction(event: UserActionRequiredEvent) {
callback.onRequiresUserAction(event)
}
override fun onProgress(step: Float) {
callback.onProgress(step)
}

View File

@ -10,6 +10,7 @@ import pl.szczodrzynski.edziennik.data.api.CODE_INTERNAL_LIBRUS_ACCOUNT_410
import pl.szczodrzynski.edziennik.data.api.edziennik.template.data.TemplateData
import pl.szczodrzynski.edziennik.data.api.edziennik.template.firstlogin.TemplateFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.template.login.TemplateLogin
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError
@ -108,6 +109,10 @@ class Template(val app: App, val profile: Profile?, val loginStore: LoginStore,
callback.onCompleted()
}
override fun onRequiresUserAction(event: UserActionRequiredEvent) {
callback.onRequiresUserAction(event)
}
override fun onProgress(step: Float) {
callback.onProgress(step)
}

View File

@ -0,0 +1,74 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-11.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_USOS_API
import pl.szczodrzynski.edziennik.data.api.models.Data
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
import pl.szczodrzynski.edziennik.data.db.entity.Profile
class DataUsos(
app: App,
profile: Profile?,
loginStore: LoginStore,
) : Data(app, profile, loginStore) {
fun isApiLoginValid() = oauthTokenKey != null && oauthTokenSecret != null && oauthTokenIsUser
override fun satisfyLoginMethods() {
loginMethods.clear()
if (isApiLoginValid()) {
loginMethods += LOGIN_METHOD_USOS_API
}
}
override fun generateUserCode() = "$schoolId:${profile?.studentNumber ?: studentId}"
var schoolId: String?
get() { mSchoolId = mSchoolId ?: loginStore.getLoginData("schoolId", null); return mSchoolId }
set(value) { loginStore.putLoginData("schoolId", value); mSchoolId = value }
private var mSchoolId: String? = null
var instanceUrl: String?
get() { mInstanceUrl = mInstanceUrl ?: loginStore.getLoginData("instanceUrl", null); return mInstanceUrl }
set(value) { loginStore.putLoginData("instanceUrl", value); mInstanceUrl = value }
private var mInstanceUrl: String? = null
var oauthLoginResponse: String?
get() { mOauthLoginResponse = mOauthLoginResponse ?: loginStore.getLoginData("oauthLoginResponse", null); return mOauthLoginResponse }
set(value) { loginStore.putLoginData("oauthLoginResponse", value); mOauthLoginResponse = value }
private var mOauthLoginResponse: String? = null
var oauthConsumerKey: String?
get() { mOauthConsumerKey = mOauthConsumerKey ?: loginStore.getLoginData("oauthConsumerKey", null); return mOauthConsumerKey }
set(value) { loginStore.putLoginData("oauthConsumerKey", value); mOauthConsumerKey = value }
private var mOauthConsumerKey: String? = null
var oauthConsumerSecret: String?
get() { mOauthConsumerSecret = mOauthConsumerSecret ?: loginStore.getLoginData("oauthConsumerSecret", null); return mOauthConsumerSecret }
set(value) { loginStore.putLoginData("oauthConsumerSecret", value); mOauthConsumerSecret = value }
private var mOauthConsumerSecret: String? = null
var oauthTokenKey: String?
get() { mOauthTokenKey = mOauthTokenKey ?: loginStore.getLoginData("oauthTokenKey", null); return mOauthTokenKey }
set(value) { loginStore.putLoginData("oauthTokenKey", value); mOauthTokenKey = value }
private var mOauthTokenKey: String? = null
var oauthTokenSecret: String?
get() { mOauthTokenSecret = mOauthTokenSecret ?: loginStore.getLoginData("oauthTokenSecret", null); return mOauthTokenSecret }
set(value) { loginStore.putLoginData("oauthTokenSecret", value); mOauthTokenSecret = value }
private var mOauthTokenSecret: String? = null
var oauthTokenIsUser: Boolean
get() { mOauthTokenIsUser = mOauthTokenIsUser ?: loginStore.getLoginData("oauthTokenIsUser", false); return mOauthTokenIsUser ?: false }
set(value) { loginStore.putLoginData("oauthTokenIsUser", value); mOauthTokenIsUser = value }
private var mOauthTokenIsUser: Boolean? = null
var studentId: Int
get() { mStudentId = mStudentId ?: profile?.getStudentData("studentId", 0); return mStudentId ?: 0 }
set(value) { profile?.putStudentData("studentId", value) ?: return; mStudentId = value }
private var mStudentId: Int? = null
}

View File

@ -0,0 +1,116 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-11.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos
import com.google.gson.JsonObject
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosData
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.firstlogin.UsosFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.login.UsosLogin
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.api.prepare
import pl.szczodrzynski.edziennik.data.api.usosLoginMethods
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.data.db.entity.Teacher
import pl.szczodrzynski.edziennik.data.db.full.AnnouncementFull
import pl.szczodrzynski.edziennik.data.db.full.EventFull
import pl.szczodrzynski.edziennik.data.db.full.MessageFull
import pl.szczodrzynski.edziennik.utils.Utils.d
class Usos(
val app: App,
val profile: Profile?,
val loginStore: LoginStore,
val callback: EdziennikCallback,
) : EdziennikInterface {
companion object {
private const val TAG = "Usos"
}
val internalErrorList = mutableListOf<Int>()
val data: DataUsos
init {
data = DataUsos(app, profile, loginStore).apply {
callback = wrapCallback(this@Usos.callback)
satisfyLoginMethods()
}
}
private fun completed() {
data.saveData()
callback.onCompleted()
}
override fun sync(
featureIds: List<Int>,
viewId: Int?,
onlyEndpoints: List<Int>?,
arguments: JsonObject?,
) {
data.arguments = arguments
data.prepare(usosLoginMethods, UsosFeatures, featureIds, viewId, onlyEndpoints)
d(TAG, "LoginMethod IDs: ${data.targetLoginMethodIds}")
d(TAG, "Endpoint IDs: ${data.targetEndpointIds}")
UsosLogin(data) {
UsosData(data) {
completed()
}
}
}
override fun getMessage(message: MessageFull) {}
override fun sendMessage(recipients: List<Teacher>, subject: String, text: String) {}
override fun markAllAnnouncementsAsRead() {}
override fun getAnnouncement(announcement: AnnouncementFull) {}
override fun getAttachment(owner: Any, attachmentId: Long, attachmentName: String) {}
override fun getRecipientList() {}
override fun getEvent(eventFull: EventFull) {}
override fun firstLogin() {
UsosFirstLogin(data) {
completed()
}
}
override fun cancel() {
d(TAG, "Cancelled")
data.cancel()
}
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {
return object : EdziennikCallback {
override fun onCompleted() {
callback.onCompleted()
}
override fun onRequiresUserAction(event: UserActionRequiredEvent) {
callback.onRequiresUserAction(event)
}
override fun onProgress(step: Float) {
callback.onProgress(step)
}
override fun onStartProgress(stringRes: Int) {
callback.onStartProgress(stringRes)
}
override fun onError(apiError: ApiError) {
when (apiError.errorCode) {
in internalErrorList -> {
// finish immediately if the same error occurs twice during the same sync
callback.onError(apiError)
}
else -> callback.onError(apiError)
}
}
}
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-11.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.models.Feature
const val ENDPOINT_USOS_API_USER = 7000
const val ENDPOINT_USOS_API_TERMS = 7010
const val ENDPOINT_USOS_API_COURSES = 7020
const val ENDPOINT_USOS_API_TIMETABLE = 7030
val UsosFeatures = listOf(
/*
* Student information
*/
Feature(LOGIN_TYPE_USOS, FEATURE_STUDENT_INFO, listOf(
ENDPOINT_USOS_API_USER to LOGIN_METHOD_USOS_API,
), listOf(LOGIN_METHOD_USOS_API)),
/*
* Terms & courses
*/
Feature(LOGIN_TYPE_USOS, FEATURE_SCHOOL_INFO, listOf(
ENDPOINT_USOS_API_TERMS to LOGIN_METHOD_USOS_API,
), listOf(LOGIN_METHOD_USOS_API)),
Feature(LOGIN_TYPE_USOS, FEATURE_TEAM_INFO, listOf(
ENDPOINT_USOS_API_COURSES to LOGIN_METHOD_USOS_API,
), listOf(LOGIN_METHOD_USOS_API)),
/*
* Timetable
*/
Feature(LOGIN_TYPE_USOS, FEATURE_TIMETABLE, listOf(
ENDPOINT_USOS_API_TIMETABLE to LOGIN_METHOD_USOS_API,
), listOf(LOGIN_METHOD_USOS_API)),
)

View File

@ -0,0 +1,209 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-13.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.data
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import im.wangchao.mhttp.Request
import im.wangchao.mhttp.Response
import im.wangchao.mhttp.body.MediaTypeUtils
import im.wangchao.mhttp.callback.JsonArrayCallbackHandler
import im.wangchao.mhttp.callback.JsonCallbackHandler
import im.wangchao.mhttp.callback.TextCallbackHandler
import pl.szczodrzynski.edziennik.data.api.ERROR_REQUEST_FAILURE
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_API_MISSING_RESPONSE
import pl.szczodrzynski.edziennik.data.api.SERVER_USER_AGENT
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.login.UsosLoginApi
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.utils.Utils.d
import java.net.HttpURLConnection.*
import java.util.UUID
open class UsosApi(open val data: DataUsos, open val lastSync: Long?) {
companion object {
private const val TAG = "UsosApi"
}
enum class ResponseType {
OBJECT,
ARRAY,
PLAIN,
}
val profileId
get() = data.profile?.id ?: -1
val profile
get() = data.profile
protected fun JsonObject.getLangString(key: String) =
this.getJsonObject(key)?.getString("pl")
protected fun JsonObject.getLecturerIds(key: String) =
this.getJsonArray(key)?.asJsonObjectList()?.mapNotNull {
val id = it.getLong("id") ?: return@mapNotNull null
val firstName = it.getString("first_name") ?: return@mapNotNull null
val lastName = it.getString("last_name") ?: return@mapNotNull null
data.getTeacher(firstName, lastName, id = id).id
} ?: listOf()
private fun valueToString(value: Any) = when (value) {
is String -> value
is Number -> value.toString()
is List<*> -> listToString(value)
else -> value.toString()
}
private fun listToString(list: List<*>): String {
return list.map {
if (it is Pair<*, *> && it.first is String && it.second is List<*>)
return@map "${it.first}[${listToString(it.second as List<*>)}]"
return@map valueToString(it ?: "")
}.joinToString("|")
}
private fun buildSignature(method: String, url: String, params: Map<String, String>): String {
val query = params.toQueryString()
val signatureString = listOf(
method.uppercase(),
url.urlEncode(),
query.urlEncode(),
).joinToString("&")
val signingKey = listOf(
data.oauthConsumerSecret ?: "",
data.oauthTokenSecret ?: "",
).joinToString("&") { it.urlEncode() }
return signatureString.hmacSHA1(signingKey)
}
fun <T> apiRequest(
tag: String,
service: String,
params: Map<String, Any>? = null,
fields: List<Any>? = null,
responseType: ResponseType,
onSuccess: (data: T, response: Response?) -> Unit,
) {
val url = "${data.instanceUrl}services/$service"
d(tag, "Request: Usos/Api - $url")
val formData = mutableMapOf<String, String>()
if (params != null)
formData.putAll(params.mapValues {
valueToString(it.value)
})
if (fields != null)
formData["fields"] = valueToString(fields)
val auth = mutableMapOf(
"realm" to url,
"oauth_consumer_key" to (data.oauthConsumerKey ?: ""),
"oauth_nonce" to UUID.randomUUID().toString(),
"oauth_signature_method" to "HMAC-SHA1",
"oauth_timestamp" to currentTimeUnix().toString(),
"oauth_token" to (data.oauthTokenKey ?: ""),
"oauth_version" to "1.0",
)
val signature = buildSignature(
method = "POST",
url = url,
params = formData + auth.filterKeys { it.startsWith("oauth_") },
)
auth["oauth_signature"] = signature
val authString = auth.map {
"""${it.key}="${it.value.urlEncode()}""""
}.joinToString(", ")
Request.builder()
.url(url)
.userAgent(SERVER_USER_AGENT)
.addHeader("Authorization", "OAuth $authString")
.post()
.setTextBody(formData.toQueryString(), MediaTypeUtils.APPLICATION_FORM)
.allowErrorCode(HTTP_BAD_REQUEST)
.allowErrorCode(HTTP_UNAUTHORIZED)
.allowErrorCode(HTTP_FORBIDDEN)
.allowErrorCode(HTTP_NOT_FOUND)
.allowErrorCode(HTTP_UNAVAILABLE)
.callback(getCallback(tag, responseType, onSuccess))
.build()
.enqueue()
}
@Suppress("UNCHECKED_CAST")
private fun <T> getCallback(
tag: String,
responseType: ResponseType,
onSuccess: (data: T, response: Response?) -> Unit,
) = when (responseType) {
ResponseType.OBJECT -> object : JsonCallbackHandler() {
override fun onSuccess(data: JsonObject?, response: Response) {
processResponse(tag, response, data as T?, onSuccess)
}
override fun onFailure(response: Response?, throwable: Throwable?) {
processError(tag, response, throwable)
}
}
ResponseType.ARRAY -> object : JsonArrayCallbackHandler() {
override fun onSuccess(data: JsonArray?, response: Response) {
processResponse(tag, response, data as T?, onSuccess)
}
override fun onFailure(response: Response?, throwable: Throwable?) {
processError(tag, response, throwable)
}
}
ResponseType.PLAIN -> object : TextCallbackHandler() {
override fun onSuccess(data: String?, response: Response) {
processResponse(tag, response, data as T?, onSuccess)
}
override fun onFailure(response: Response?, throwable: Throwable?) {
processError(tag, response, throwable)
}
}
}
private fun <T> processResponse(
tag: String,
response: Response,
value: T?,
onSuccess: (data: T, response: Response?) -> Unit,
) {
val errorCode = when {
response.code() == HTTP_UNAUTHORIZED -> {
data.oauthTokenKey = null
data.oauthTokenSecret = null
data.oauthTokenIsUser = false
data.oauthLoginResponse = null
UsosLoginApi(data) { }
return
}
value == null -> ERROR_USOS_API_MISSING_RESPONSE
response.code() == HTTP_OK -> {
onSuccess(value, response)
null
}
else -> response.toErrorCode()
}
if (errorCode != null) {
data.error(tag, errorCode, response, value.toString())
}
}
private fun processError(
tag: String,
response: Response?,
throwable: Throwable?,
) {
data.error(ApiError(tag, ERROR_REQUEST_FAILURE)
.withResponse(response)
.withThrowable(throwable))
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-13.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.data
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.edziennik.template.data.web.TemplateWebSample
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.*
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiCourses
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiTerms
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiTimetable
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api.UsosApiUser
import pl.szczodrzynski.edziennik.utils.Utils.d
class UsosData(val data: DataUsos, val onSuccess: () -> Unit) {
companion object {
private const val TAG = "UsosData"
}
init {
nextEndpoint(onSuccess)
}
private fun nextEndpoint(onSuccess: () -> Unit) {
if (data.targetEndpointIds.isEmpty()) {
onSuccess()
return
}
if (data.cancelled) {
onSuccess()
return
}
val id = data.targetEndpointIds.firstKey()
val lastSync = data.targetEndpointIds.remove(id)
useEndpoint(id, lastSync) { endpointId ->
data.progress(data.progressStep)
nextEndpoint(onSuccess)
}
}
private fun useEndpoint(endpointId: Int, lastSync: Long?, onSuccess: (endpointId: Int) -> Unit) {
d(TAG, "Using endpoint $endpointId. Last sync time = $lastSync")
when (endpointId) {
ENDPOINT_USOS_API_USER -> {
data.startProgress(R.string.edziennik_progress_endpoint_student_info)
UsosApiUser(data, lastSync, onSuccess)
}
ENDPOINT_USOS_API_TERMS -> {
data.startProgress(R.string.edziennik_progress_endpoint_school_info)
UsosApiTerms(data, lastSync, onSuccess)
}
ENDPOINT_USOS_API_COURSES -> {
data.startProgress(R.string.edziennik_progress_endpoint_teams)
UsosApiCourses(data, lastSync, onSuccess)
}
ENDPOINT_USOS_API_TIMETABLE -> {
data.startProgress(R.string.edziennik_progress_endpoint_timetable)
UsosApiTimetable(data, lastSync, onSuccess)
}
else -> onSuccess(endpointId)
}
}
}

View File

@ -0,0 +1,87 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-15.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api
import com.google.gson.JsonObject
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_API_INCOMPLETE_RESPONSE
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.ENDPOINT_USOS_API_COURSES
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.db.entity.Team
import pl.szczodrzynski.edziennik.ext.*
class UsosApiCourses(
override val data: DataUsos,
override val lastSync: Long?,
val onSuccess: (endpointId: Int) -> Unit,
) : UsosApi(data, lastSync) {
companion object {
const val TAG = "UsosApiCourses"
}
init {
apiRequest<JsonObject>(
tag = TAG,
service = "courses/user",
fields = listOf(
// "terms" to listOf("id", "name", "start_date", "end_date"),
"course_editions" to listOf(
"course_id",
"course_name",
// "term_id",
"user_groups" to listOf(
"course_unit_id",
"group_number",
// "class_type",
"class_type_id",
"lecturers",
),
),
),
responseType = ResponseType.OBJECT,
) { json, response ->
if (!processResponse(json)) {
data.error(TAG, ERROR_USOS_API_INCOMPLETE_RESPONSE, response)
return@apiRequest
}
data.setSyncNext(ENDPOINT_USOS_API_COURSES, 2 * DAY)
onSuccess(ENDPOINT_USOS_API_COURSES)
}
}
private fun processResponse(json: JsonObject): Boolean {
// val term = json.getJsonArray("terms")?.firstOrNull() ?: return false
val courseEditions = json.getJsonObject("course_editions")
?.entrySet()
?.flatMap { it.value.asJsonArray }
?.map { it.asJsonObject } ?: return false
var hasValidTeam = false
for (courseEdition in courseEditions) {
val courseId = courseEdition.getString("course_id") ?: continue
val courseName = courseEdition.getLangString("course_name") ?: continue
val userGroups = courseEdition.getJsonArray("user_groups")?.asJsonObjectList() ?: continue
for (userGroup in userGroups) {
val courseUnitId = userGroup.getLong("course_unit_id") ?: continue
val groupNumber = userGroup.getInt("group_number") ?: continue
// val classType = userGroup.getLangString("class_type") ?: continue
val classTypeId = userGroup.getString("class_type_id") ?: continue
val lecturers = userGroup.getLecturerIds("lecturers")
data.teamList.put(courseUnitId, Team(
profileId,
courseUnitId,
"${profile?.studentClassName} $classTypeId$groupNumber - $courseName",
2,
"${data.schoolId}:${courseId} $classTypeId$groupNumber",
lecturers.firstOrNull() ?: -1L,
))
hasValidTeam = true
}
}
return hasValidTeam
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-15.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api
import com.google.gson.JsonArray
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_API_INCOMPLETE_RESPONSE
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.ENDPOINT_USOS_API_TERMS
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.utils.models.Date
class UsosApiTerms(
override val data: DataUsos,
override val lastSync: Long?,
val onSuccess: (endpointId: Int) -> Unit,
) : UsosApi(data, lastSync) {
companion object {
const val TAG = "UsosApiTerms"
}
init {
apiRequest<JsonArray>(
tag = TAG,
service = "terms/search",
params = mapOf(
"query" to Date.getToday().year.toString(),
),
responseType = ResponseType.ARRAY,
) { json, response ->
if (!processResponse(json)) {
data.error(TAG, ERROR_USOS_API_INCOMPLETE_RESPONSE, response)
return@apiRequest
}
data.setSyncNext(ENDPOINT_USOS_API_TERMS, 7 * DAY)
onSuccess(ENDPOINT_USOS_API_TERMS)
}
}
private fun processResponse(json: JsonArray): Boolean {
val dates = mutableSetOf<Date>()
for (term in json.asJsonObjectList()) {
if (!term.getBoolean("is_active", false))
continue
val startDate = term.getString("start_date")?.let { Date.fromY_m_d(it) }
val finishDate = term.getString("finish_date")?.let { Date.fromY_m_d(it) }
if (startDate != null)
dates += startDate
if (finishDate != null)
dates += finishDate
}
val datesSorted = dates.sorted()
if (datesSorted.size != 3)
return false
profile?.studentSchoolYearStart = datesSorted[0].year
profile?.dateSemester1Start = datesSorted[0]
profile?.dateSemester2Start = datesSorted[1]
profile?.dateYearEnd = datesSorted[2]
return true
}
}

View File

@ -0,0 +1,141 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-16.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api
import com.google.gson.JsonArray
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_API_INCOMPLETE_RESPONSE
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.ENDPOINT_USOS_API_TIMETABLE
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.api.models.DataRemoveModel
import pl.szczodrzynski.edziennik.data.db.entity.Lesson
import pl.szczodrzynski.edziennik.data.db.entity.Metadata
import pl.szczodrzynski.edziennik.data.db.entity.SYNC_ALWAYS
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.Time
import pl.szczodrzynski.edziennik.utils.models.Week
class UsosApiTimetable(
override val data: DataUsos,
override val lastSync: Long?,
val onSuccess: (endpointId: Int) -> Unit,
) : UsosApi(data, lastSync) {
companion object {
const val TAG = "UsosApiTimetable"
}
init {
val currentWeekStart = Week.getWeekStart()
if (Date.getToday().weekDay > 4)
currentWeekStart.stepForward(0, 0, 7)
val weekStart = data.arguments
?.getString("weekStart")
?.let { Date.fromY_m_d(it) }
?: currentWeekStart
val weekEnd = weekStart.clone().stepForward(0, 0, 6)
apiRequest<JsonArray>(
tag = TAG,
service = "tt/user",
params = mapOf(
"start" to weekStart.stringY_m_d,
"days" to 7,
),
fields = listOf(
"type",
"start_time",
"end_time",
"unit_id",
"course_id",
"course_name",
"lecturer_ids",
"building_id",
"room_number",
"classtype_id",
"group_number",
),
responseType = ResponseType.ARRAY,
) { json, response ->
if (!processResponse(json, weekStart..weekEnd)) {
data.error(TAG, ERROR_USOS_API_INCOMPLETE_RESPONSE, response)
return@apiRequest
}
data.toRemove.add(DataRemoveModel.Timetable.between(weekStart, weekEnd))
data.setSyncNext(ENDPOINT_USOS_API_TIMETABLE, SYNC_ALWAYS)
onSuccess(ENDPOINT_USOS_API_TIMETABLE)
}
}
private fun processResponse(json: JsonArray, syncRange: ClosedRange<Date>): Boolean {
val foundDates = mutableSetOf<Date>()
for (activity in json.asJsonObjectList()) {
val type = activity.getString("type")
if (type !in listOf("classgroup", "classgroup2"))
continue
val startTime = activity.getString("start_time") ?: continue
val endTime = activity.getString("end_time") ?: continue
val unitId = activity.getLong("unit_id", -1)
val courseName = activity.getLangString("course_name") ?: continue
val courseId = activity.getString("course_id") ?: continue
val lecturerIds = activity.getJsonArray("lecturer_ids")?.map { it.asLong }
val buildingId = activity.getString("building_id")
val roomNumber = activity.getString("room_number")
val classTypeId = activity.getString("classtype_id")
val groupNumber = activity.getString("group_number")
val lesson = Lesson(profileId, -1).also {
it.type = Lesson.TYPE_NORMAL
it.date = Date.fromY_m_d(startTime)
it.startTime = Time.fromY_m_d_H_m_s(startTime)
it.endTime = Time.fromY_m_d_H_m_s(endTime)
it.subjectId = data.getSubject(
id = null,
name = courseName,
shortName = courseId,
).id
it.teacherId = lecturerIds?.firstOrNull() ?: -1L
it.teamId = unitId
val groupName = classTypeId?.plus(groupNumber)?.let { s -> "($s)" }
it.classroom = "$buildingId / $roomNumber ${groupName ?: ""}"
it.id = it.buildId()
it.color = when (classTypeId) {
"WYK" -> 0xff0d6091
"CW" -> 0xff54306e
"LAB" -> 0xff772747
"KON" -> 0xff1e5128
"^P?SEM" -> 0xff1e5128 // TODO make it regex
else -> 0xff08534c
}.toInt()
}
lesson.date?.let { foundDates += it }
val seen = profile?.empty != false || lesson.date!! < Date.getToday()
data.lessonList.add(lesson)
if (lesson.type != Lesson.TYPE_NORMAL)
data.metadataList += Metadata(
profileId,
Metadata.TYPE_LESSON_CHANGE,
lesson.id,
seen,
seen,
)
}
val notFoundDates = syncRange.asSequence() - foundDates
for (date in notFoundDates) {
data.lessonList += Lesson(profileId, date.value.toLong()).also {
it.type = Lesson.TYPE_NO_LESSONS
it.date = date
}
}
return true
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-16.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.api
import com.google.gson.JsonObject
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_NO_STUDENT_PROGRAMMES
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.ENDPOINT_USOS_API_USER
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.*
class UsosApiUser(
override val data: DataUsos,
override val lastSync: Long?,
val onSuccess: (endpointId: Int) -> Unit,
) : UsosApi(data, lastSync) {
companion object {
const val TAG = "UsosApiUser"
}
init {
apiRequest<JsonObject>(
tag = TAG,
service = "users/user",
params = mapOf(
"fields" to listOf(
"id",
"first_name",
"last_name",
"student_number",
"student_programmes" to listOf(
"programme" to listOf("id"),
),
),
),
responseType = ResponseType.OBJECT,
) { json, response ->
val programmes = json.getJsonArray("student_programmes")
if (programmes.isNullOrEmpty()) {
data.error(ApiError(TAG, ERROR_USOS_NO_STUDENT_PROGRAMMES)
.withApiResponse(json)
.withResponse(response))
return@apiRequest
}
val firstName = json.getString("first_name")
val lastName = json.getString("last_name")
val studentName = buildFullName(firstName, lastName)
data.studentId = json.getInt("id") ?: data.studentId
profile?.studentNameLong = studentName
profile?.studentNameShort = studentName.getShortName()
profile?.studentNumber = json.getInt("student_number", -1)
profile?.studentClassName = programmes.getJsonObject(0).getJsonObject("programme").getString("id")
profile?.studentClassName?.let {
data.getTeam(
id = null,
name = it,
schoolCode = data.schoolId ?: "",
isTeamClass = true,
)
}
data.setSyncNext(ENDPOINT_USOS_API_USER, 4 * DAY)
onSuccess(ENDPOINT_USOS_API_USER)
}
}
}

View File

@ -0,0 +1,84 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-14.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.firstlogin
import com.google.gson.JsonObject
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.data.api.ERROR_USOS_NO_STUDENT_PROGRAMMES
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_USOS
import pl.szczodrzynski.edziennik.data.api.edziennik.librus.firstlogin.LibrusFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.login.UsosLoginApi
import pl.szczodrzynski.edziennik.data.api.events.FirstLoginFinishedEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.ext.*
class UsosFirstLogin(val data: DataUsos, val onSuccess: () -> Unit) {
companion object {
private const val TAG = "UsosFirstLogin"
}
private val api = UsosApi(data, null)
init {
val loginStoreId = data.loginStore.id
val loginStoreType = LOGIN_TYPE_USOS
var firstProfileId = loginStoreId
UsosLoginApi(data) {
api.apiRequest<JsonObject>(
tag = TAG,
service = "users/user",
params = mapOf(
"fields" to listOf(
"id",
"first_name",
"last_name",
"student_number",
"student_programmes" to listOf(
"programme" to listOf("id"),
),
),
),
responseType = UsosApi.ResponseType.OBJECT,
) { json, response ->
val programmes = json.getJsonArray("student_programmes")
if (programmes.isNullOrEmpty()) {
data.error(ApiError(TAG, ERROR_USOS_NO_STUDENT_PROGRAMMES)
.withApiResponse(json)
.withResponse(response))
return@apiRequest
}
val firstName = json.getString("first_name")
val lastName = json.getString("last_name")
val studentName = buildFullName(firstName, lastName)
val profile = Profile(
id = firstProfileId++,
loginStoreId = loginStoreId, loginStoreType = loginStoreType,
name = studentName,
subname = data.schoolId,
studentNameLong = studentName,
studentNameShort = studentName.getShortName(),
accountName = null, // student account
studentData = JsonObject(
"studentId" to json.getInt("id"),
),
).also {
it.studentNumber = json.getInt("student_number", -1)
it.studentClassName = programmes.getJsonObject(0).getJsonObject("programme").getString("id")
}
EventBus.getDefault().postSticky(
FirstLoginFinishedEvent(listOf(profile), data.loginStore),
)
onSuccess()
}
}
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-11.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.login
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_USOS_API
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.utils.Utils.d
class UsosLogin(val data: DataUsos, val onSuccess: () -> Unit) {
companion object {
private const val TAG = "UsosLogin"
}
private var cancelled = false
init {
nextLoginMethod(onSuccess)
}
private fun nextLoginMethod(onSuccess: () -> Unit) {
if (data.targetLoginMethodIds.isEmpty()) {
onSuccess()
return
}
if (cancelled) {
onSuccess()
return
}
useLoginMethod(data.targetLoginMethodIds.removeAt(0)) { usedMethodId ->
data.progress(data.progressStep)
if (usedMethodId != -1)
data.loginMethods.add(usedMethodId)
nextLoginMethod(onSuccess)
}
}
private fun useLoginMethod(loginMethodId: Int, onSuccess: (usedMethodId: Int) -> Unit) {
// this should never be true
if (data.loginMethods.contains(loginMethodId)) {
onSuccess(-1)
return
}
d(TAG, "Using login method $loginMethodId")
when (loginMethodId) {
LOGIN_METHOD_USOS_API -> {
data.startProgress(R.string.edziennik_progress_login_usos_api)
UsosLoginApi(data) { onSuccess(loginMethodId) }
}
}
}
}

View File

@ -0,0 +1,113 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-11.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik.usos.login
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.DataUsos
import pl.szczodrzynski.edziennik.data.api.edziennik.usos.data.UsosApi
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.utils.Utils.d
class UsosLoginApi(val data: DataUsos, val onSuccess: () -> Unit) {
companion object {
private const val TAG = "UsosLoginApi"
}
private val api = UsosApi(data, null)
init {
run {
data.arguments?.getString("oauthLoginResponse")?.let {
data.oauthLoginResponse = it
}
if (data.isApiLoginValid()) {
onSuccess()
} else if (data.oauthLoginResponse != null) {
login()
} else {
authorize()
}
}
}
private fun authorize() {
data.oauthTokenKey = null
data.oauthTokenSecret = null
api.apiRequest<String>(
tag = TAG,
service = "oauth/request_token",
params = mapOf(
"oauth_callback" to USOS_API_OAUTH_REDIRECT_URL,
"scopes" to USOS_API_SCOPES,
),
responseType = UsosApi.ResponseType.PLAIN,
) { text, _ ->
val authorizeData = text.fromQueryString()
data.oauthTokenKey = authorizeData["oauth_token"]
data.oauthTokenSecret = authorizeData["oauth_token_secret"]
data.oauthTokenIsUser = false
val authUrl = "${data.instanceUrl}services/oauth/authorize"
val authParams = mapOf(
"interactivity" to "confirm_user",
"oauth_token" to (data.oauthTokenKey ?: ""),
)
data.requireUserAction(
type = UserActionRequiredEvent.Type.OAUTH,
params = Bundle(
"authorizeUrl" to "$authUrl?${authParams.toQueryString()}",
"redirectUrl" to USOS_API_OAUTH_REDIRECT_URL,
"responseStoreKey" to "oauthLoginResponse",
"extras" to data.loginStore.data.toBundle(),
),
errorText = R.string.notification_user_action_required_oauth_usos,
)
}
}
private fun login() {
d(TAG, "Login to ${data.schoolId} with ${data.oauthLoginResponse}")
val authorizeResponse = data.oauthLoginResponse?.fromQueryString()
?: return // checked in init {}
if (authorizeResponse["oauth_token"] != data.oauthTokenKey) {
// got different token
data.error(ApiError(TAG, ERROR_USOS_OAUTH_GOT_DIFFERENT_TOKEN)
.withApiResponse(data.oauthLoginResponse))
return
}
val verifier = authorizeResponse["oauth_verifier"]
if (verifier.isNullOrBlank()) {
data.error(ApiError(TAG, ERROR_USOS_OAUTH_INCOMPLETE_RESPONSE)
.withApiResponse(data.oauthLoginResponse))
return
}
api.apiRequest<String>(
tag = TAG,
service = "oauth/access_token",
params = mapOf(
"oauth_verifier" to verifier,
),
responseType = UsosApi.ResponseType.PLAIN,
) { text, response ->
val accessData = text.fromQueryString()
data.oauthTokenKey = accessData["oauth_token"]
data.oauthTokenSecret = accessData["oauth_token_secret"]
data.oauthTokenIsUser = data.oauthTokenKey != null && data.oauthTokenSecret != null
data.loginStore.removeLoginData("oauthLoginResponse")
if (!data.oauthTokenIsUser)
data.error(ApiError(TAG, ERROR_USOS_OAUTH_INCOMPLETE_RESPONSE)
.withApiResponse(text)
.withResponse(response))
else
onSuccess()
}
}
}

View File

@ -17,6 +17,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLogin
import pl.szczodrzynski.edziennik.data.api.events.AttachmentGetEvent
import pl.szczodrzynski.edziennik.data.api.events.EventGetEvent
import pl.szczodrzynski.edziennik.data.api.events.MessageGetEvent
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError
@ -179,6 +180,7 @@ class Vulcan(val app: App, val profile: Profile?, val loginStore: LoginStore, va
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {
return object : EdziennikCallback {
override fun onCompleted() { callback.onCompleted() }
override fun onRequiresUserAction(event: UserActionRequiredEvent) { callback.onRequiresUserAction(event) }
override fun onProgress(step: Float) { callback.onProgress(step) }
override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) }
override fun onError(apiError: ApiError) {

View File

@ -4,11 +4,16 @@
package pl.szczodrzynski.edziennik.data.api.events
data class UserActionRequiredEvent(val profileId: Int, val type: Int) {
companion object {
const val LOGIN_DATA_MOBIDZIENNIK = 101
const val LOGIN_DATA_LIBRUS = 102
const val LOGIN_DATA_VULCAN = 104
const val CAPTCHA_LIBRUS = 202
import android.os.Bundle
data class UserActionRequiredEvent(
val profileId: Int?,
val type: Type,
val params: Bundle,
val errorText: Int,
) {
enum class Type {
RECAPTCHA,
OAUTH,
}
}

View File

@ -4,6 +4,7 @@
package pl.szczodrzynski.edziennik.data.api.interfaces
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.models.Feature
import pl.szczodrzynski.edziennik.data.api.models.LoginMethod
@ -14,4 +15,5 @@ import pl.szczodrzynski.edziennik.data.api.models.LoginMethod
*/
interface EdziennikCallback : EndpointCallback {
fun onCompleted()
fun onRequiresUserAction(event: UserActionRequiredEvent)
}

View File

@ -5,6 +5,7 @@
package pl.szczodrzynski.edziennik.data.api.models
import android.content.Context
import android.os.Bundle
import com.google.gson.JsonObject
import im.wangchao.mhttp.Request
import im.wangchao.mhttp.Response
@ -30,6 +31,7 @@ class ApiError(val tag: String, var errorCode: Int) {
var request: Request? = null
var response: Response? = null
var isCritical = true
var params: Bundle? = null
fun withThrowable(throwable: Throwable?): ApiError {
this.throwable = throwable
@ -58,6 +60,11 @@ class ApiError(val tag: String, var errorCode: Int) {
return this
}
fun withParams(bundle: Bundle): ApiError {
this.params = bundle
return this
}
fun getStringText(context: Context): String {
return context.resources.getIdentifier("error_${errorCode}", "string", context.packageName).let {
if (it != 0)

View File

@ -1,5 +1,6 @@
package pl.szczodrzynski.edziennik.data.api.models
import android.os.Bundle
import android.util.LongSparseArray
import android.util.SparseArray
import androidx.core.util.set
@ -12,7 +13,8 @@ import pl.szczodrzynski.edziennik.BuildConfig
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.ERROR_REQUEST_FAILURE
import pl.szczodrzynski.edziennik.data.api.Regexes.MESSAGE_META
import pl.szczodrzynski.edziennik.data.api.interfaces.EndpointCallback
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.db.AppDb
import pl.szczodrzynski.edziennik.data.db.entity.*
import pl.szczodrzynski.edziennik.ext.*
@ -37,7 +39,7 @@ abstract class Data(val app: App, val profile: Profile?, val loginStore: LoginSt
/**
* A callback passed to all [Feature]s and [LoginMethod]s
*/
lateinit var callback: EndpointCallback
lateinit var callback: EdziennikCallback
/**
* A list of [LoginMethod]s *already fulfilled* during this sync.
@ -374,6 +376,15 @@ abstract class Data(val app: App, val profile: Profile?, val loginStore: LoginSt
callback.onError(apiError)
}
fun requireUserAction(type: UserActionRequiredEvent.Type, params: Bundle, errorText: Int) {
callback.onRequiresUserAction(UserActionRequiredEvent(
profileId = profile?.id,
type = type,
params = params,
errorText = errorText,
))
}
fun progress(step: Float) {
callback.onProgress(step)
}
@ -438,14 +449,14 @@ abstract class Data(val app: App, val profile: Profile?, val loginStore: LoginSt
return team
}
fun getTeacher(firstName: String, lastName: String, loginId: String? = null): Teacher {
fun getTeacher(firstName: String, lastName: String, loginId: String? = null, id: Long? = null): Teacher {
val teacher = teacherList.singleOrNull { it.fullName == "$firstName $lastName" }
return validateTeacher(teacher, firstName, lastName, loginId)
return validateTeacher(teacher, firstName, lastName, loginId, id)
}
fun getTeacher(firstNameChar: Char, lastName: String, loginId: String? = null): Teacher {
val teacher = teacherList.singleOrNull { it.shortName == "$firstNameChar.$lastName" }
return validateTeacher(teacher, firstNameChar.toString(), lastName, loginId)
return validateTeacher(teacher, firstNameChar.toString(), lastName, loginId, null)
}
fun getTeacherByLastFirst(nameLastFirst: String, loginId: String? = null): Teacher {
@ -453,9 +464,9 @@ abstract class Data(val app: App, val profile: Profile?, val loginStore: LoginSt
val teacher = teacherList.singleOrNull { it.fullNameLastFirst == nameLastFirst }
val nameParts = nameLastFirst.split(" ", limit = 2)
return if (nameParts.size == 1)
validateTeacher(teacher, nameParts[0], "", loginId)
validateTeacher(teacher, nameParts[0], "", loginId, null)
else
validateTeacher(teacher, nameParts[1], nameParts[0], loginId)
validateTeacher(teacher, nameParts[1], nameParts[0], loginId, null)
}
fun getTeacherByFirstLast(nameFirstLast: String, loginId: String? = null): Teacher {
@ -463,9 +474,9 @@ abstract class Data(val app: App, val profile: Profile?, val loginStore: LoginSt
val teacher = teacherList.singleOrNull { it.fullName == nameFirstLast }
val nameParts = nameFirstLast.split(" ", limit = 2)
return if (nameParts.size == 1)
validateTeacher(teacher, nameParts[0], "", loginId)
validateTeacher(teacher, nameParts[0], "", loginId, null)
else
validateTeacher(teacher, nameParts[0], nameParts[1], loginId)
validateTeacher(teacher, nameParts[0], nameParts[1], loginId, null)
}
fun getTeacherByFDotLast(nameFDotLast: String, loginId: String? = null): Teacher {
@ -484,10 +495,16 @@ abstract class Data(val app: App, val profile: Profile?, val loginStore: LoginSt
getTeacher(nameParts[0][0], nameParts[1], loginId)
}
private fun validateTeacher(teacher: Teacher?, firstName: String, lastName: String, loginId: String?): Teacher {
val obj = teacher ?: Teacher(profileId, -1, firstName, lastName, loginId).apply {
id = fullName.crc32()
teacherList[id] = this
private fun validateTeacher(
teacher: Teacher?,
firstName: String,
lastName: String,
loginId: String?,
id: Long?
): Teacher {
val obj = teacher ?: Teacher(profileId, -1, firstName, lastName, loginId).also {
it.id = id ?: it.fullName.crc32()
teacherList[it.id] = it
}
return obj.also {
if (loginId != null)

View File

@ -451,9 +451,9 @@ class SzkolnyApi(val app: App) : CoroutineScope {
@Throws(Exception::class)
fun getRealms(registerName: String): List<LoginInfo.Platform> {
val response = api.fsLoginRealms(registerName).execute()
val response = api.platforms(registerName).execute()
if (response.isSuccessful && response.body() != null) {
return response.body()!!
return parseResponse(response)
}
throw SzkolnyApiException(null)
}

View File

@ -45,6 +45,6 @@ interface SzkolnyService {
@GET("registerAvailability")
fun registerAvailability(): Call<ApiResponse<Map<String, RegisterAvailabilityStatus>>>
@GET("https://szkolny-eu.github.io/FSLogin/realms/{registerName}.json")
fun fsLoginRealms(@Path("registerName") registerName: String): Call<List<LoginInfo.Platform>>
@GET("platforms/{registerName}")
fun platforms(@Path("registerName") registerName: String): Call<ApiResponse<List<LoginInfo.Platform>>>
}

View File

@ -46,6 +46,6 @@ object Signing {
/*fun provideKey(param1: String, param2: Long): ByteArray {*/
fun pleaseStopRightNow(param1: String, param2: Long): ByteArray {
return "$param1.MTIzNDU2Nzg5MDRaOIVjKV===.$param2".sha256()
return "$param1.MTIzNDU2Nzg5MDl2tMYSgy===.$param2".sha256()
}
}

View File

@ -44,7 +44,7 @@ import pl.szczodrzynski.edziennik.data.db.migration.*
TimetableManual::class,
Note::class,
Metadata::class
], version = 97)
], version = 98)
@TypeConverters(
ConverterTime::class,
ConverterDate::class,
@ -185,6 +185,7 @@ abstract class AppDb : RoomDatabase() {
Migration95(),
Migration96(),
Migration97(),
Migration98(),
).allowMainThreadQueries().build()
}
}

View File

@ -74,6 +74,8 @@ open class Lesson(
@Ignore
var showAsUnseen = false
var color: Int? = null
override fun toString(): String {
return "Lesson(profileId=$profileId, " +
"id=$id, " +

View File

@ -233,6 +233,10 @@ open class Profile(
MainActivity.DRAWER_ITEM_GRADES,
MainActivity.DRAWER_ITEM_HOMEWORK
)
LOGIN_TYPE_USOS -> listOf(
MainActivity.DRAWER_ITEM_TIMETABLE,
MainActivity.DRAWER_ITEM_AGENDA
)
else -> listOf(
MainActivity.DRAWER_ITEM_TIMETABLE,
MainActivity.DRAWER_ITEM_AGENDA,

View File

@ -0,0 +1,15 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-16.
*/
package pl.szczodrzynski.edziennik.data.db.migration
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration98 : Migration(97, 98) {
override fun migrate(database: SupportSQLiteDatabase) {
// timetable colors - override color in lesson object
database.execSQL("ALTER TABLE timetable ADD COLUMN color INT DEFAULT NULL;")
}
}

View File

@ -22,6 +22,15 @@ fun Bundle?.getFloat(key: String, defaultValue: Float): Float {
fun Bundle?.getString(key: String, defaultValue: String): String {
return this?.getString(key, defaultValue) ?: defaultValue
}
inline fun <reified E : Enum<E>> Bundle?.getEnum(key: String): E? {
return this?.getString(key)?.let {
try {
enumValueOf<E>(it)
} catch (e: Exception) {
null
}
}
}
fun Bundle?.getIntOrNull(key: String): Int? {
return this?.get(key) as? Int
@ -48,6 +57,7 @@ fun Bundle(vararg properties: Pair<String, Any?>): Bundle {
is Bundle -> putBundle(property.first, property.second as Bundle)
is Parcelable -> putParcelable(property.first, property.second as Parcelable)
is Array<*> -> putParcelableArray(property.first, property.second as Array<out Parcelable>)
is Enum<*> -> putString(property.first, (property.second as Enum<*>).name)
}
}
}

View File

@ -10,6 +10,8 @@ import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.JsonPrimitive
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
fun JsonObject?.get(key: String): JsonElement? = this?.get(key)
@ -93,7 +95,13 @@ fun JsonArray(vararg properties: Any?): JsonArray {
}
}
fun JsonArray?.isNullOrEmpty(): Boolean = (this?.size() ?: 0) == 0
@OptIn(ExperimentalContracts::class)
fun JsonArray?.isNullOrEmpty(): Boolean {
contract {
returns(false) implies (this@isNullOrEmpty != null)
}
return this == null || this.isEmpty
}
operator fun JsonArray.plusAssign(o: JsonElement) = this.add(o)
operator fun JsonArray.plusAssign(o: String) = this.add(o)
operator fun JsonArray.plusAssign(o: Char) = this.add(o)

View File

@ -18,6 +18,8 @@ import android.text.style.StyleSpan
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.mikepenz.materialdrawer.holder.StringHolder
import java.net.URLDecoder
import java.net.URLEncoder
fun CharSequence?.isNotNullNorEmpty(): Boolean {
return this != null && this.isNotEmpty()
@ -343,3 +345,17 @@ fun Int.toStringHolder() = StringHolder(this)
fun CharSequence.toStringHolder() = StringHolder(this)
fun @receiver:StringRes Int.resolveString(context: Context) = context.getString(this)
fun String.urlEncode(): String = URLEncoder.encode(this, "UTF-8").replace("+", "%20")
fun String.urlDecode(): String = URLDecoder.decode(this, "UTF-8")
fun Map<String, String>.toQueryString() = this
.map { it.key.urlEncode() to it.value.urlEncode() }
.sortedBy { it.first }
.joinToString("&") { "${it.first}=${it.second}" }
fun String.fromQueryString() = this
.substringAfter('?')
.split("&")
.map { it.split("=") }
.associate { it[0].urlDecode() to it[1].urlDecode() }

View File

@ -7,9 +7,10 @@ package pl.szczodrzynski.edziennik.ext
import android.content.Context
import im.wangchao.mhttp.Response
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.Time
import java.text.SimpleDateFormat
import java.util.*
import java.util.Locale
const val MINUTE = 60L
const val HOUR = 60L*MINUTE
@ -115,3 +116,11 @@ fun Context.getSyncInterval(interval: Int): String {
""
return hoursText?.plus(" $minutesText") ?: minutesText
}
fun ClosedRange<Date>.asSequence(): Sequence<Date> = sequence {
val date = this@asSequence.start.clone()
while (date in this@asSequence) {
yield(date.clone())
date.stepForward(0, 0, 1)
}
}

View File

@ -13,14 +13,16 @@ import pl.szczodrzynski.edziennik.databinding.RecaptchaViewBinding
import pl.szczodrzynski.edziennik.ext.onClick
import pl.szczodrzynski.edziennik.ui.dialogs.base.BindingDialog
class LibrusCaptchaDialog(
class RecaptchaPromptDialog(
activity: AppCompatActivity,
private val siteKey: String,
private val referer: String,
private val onSuccess: (recaptchaCode: String) -> Unit,
private val onFailure: (() -> Unit)?,
private val onCancel: (() -> Unit)?,
onShowListener: ((tag: String) -> Unit)? = null,
onDismissListener: ((tag: String) -> Unit)? = null,
) : BindingDialog<RecaptchaViewBinding>(activity, onShowListener, onDismissListener) {
override val TAG = "LibrusCaptchaDialog"
override val TAG = "RecaptchaPromptDialog"
override fun getTitleRes(): Int? = null
override fun inflate(layoutInflater: LayoutInflater) =
@ -46,8 +48,8 @@ class LibrusCaptchaDialog(
b.progress.visibility = View.VISIBLE
RecaptchaDialog(
activity,
siteKey = "6Lf48moUAAAAAB9ClhdvHr46gRWR-CN31CXQPG2U",
referer = "https://portal.librus.pl/rodzina/login",
siteKey = siteKey,
referer = referer,
onSuccess = { recaptchaCode ->
b.checkbox.background = checkboxBackground
b.checkbox.foreground = checkboxForeground
@ -67,6 +69,6 @@ class LibrusCaptchaDialog(
override fun onDismiss() {
if (!success)
onFailure?.invoke()
onCancel?.invoke()
}
}

View File

@ -0,0 +1,44 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-7.
*/
package pl.szczodrzynski.edziennik.ui.dialogs.settings
import android.view.LayoutInflater
import androidx.appcompat.app.AppCompatActivity
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.databinding.TimetableConfigDialogBinding
import pl.szczodrzynski.edziennik.ext.Intent
import pl.szczodrzynski.edziennik.ext.onClick
import pl.szczodrzynski.edziennik.ui.dialogs.base.ConfigDialog
import pl.szczodrzynski.edziennik.ui.timetable.TimetableFragment
class TimetableConfigDialog(
activity: AppCompatActivity,
reloadOnDismiss: Boolean = true,
onShowListener: ((tag: String) -> Unit)? = null,
onDismissListener: ((tag: String) -> Unit)? = null,
) : ConfigDialog<TimetableConfigDialogBinding>(
activity,
reloadOnDismiss,
onShowListener,
onDismissListener,
) {
override val TAG = "TimetableConfigDialog"
override fun getTitleRes() = R.string.menu_timetable_config
override fun inflate(layoutInflater: LayoutInflater) =
TimetableConfigDialogBinding.inflate(layoutInflater)
private val profileConfig by lazy { app.config.getFor(app.profileId).ui }
override suspend fun loadConfig() {
b.config = profileConfig
}
override suspend fun saveConfig() {
activity.sendBroadcast(Intent(TimetableFragment.ACTION_RELOAD_PAGES))
}
}

View File

@ -81,7 +81,8 @@ class HomeFragment : Fragment(), CoroutineScope {
private val job: Job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
private val manager
get() = app.permissionManager
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null
context ?: return null
@ -96,6 +97,10 @@ class HomeFragment : Fragment(), CoroutineScope {
if (!isAdded)
return
if (!manager.isNotificationPermissionGranted) {
manager.requestNotificationsPermission(activity, 0, false){}
}
activity.bottomSheet.prependItems(
BottomSheetPrimaryItem(true)
.withTitle(R.string.menu_add_remove_cards)

View File

@ -27,7 +27,7 @@ import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.databinding.CardHomeDebugBinding
import pl.szczodrzynski.edziennik.ext.dp
import pl.szczodrzynski.edziennik.ext.onClick
import pl.szczodrzynski.edziennik.ui.captcha.LibrusCaptchaDialog
import pl.szczodrzynski.edziennik.ui.captcha.RecaptchaPromptDialog
import pl.szczodrzynski.edziennik.ui.home.HomeCard
import pl.szczodrzynski.edziennik.ui.home.HomeCardAdapter
import pl.szczodrzynski.edziennik.ui.home.HomeFragment
@ -85,11 +85,6 @@ class HomeDebugCard(
app.startActivity(Chucker.getLaunchIntent(activity, 1));
}
b.librusCaptchaButton.onClick {
//app.startActivity(Intent(activity, LoginLibrusCaptchaActivity::class.java))
LibrusCaptchaDialog(activity, onSuccess = {}, onFailure = {}).show()
}
b.getLogs.onClick {
val logs = HyperLog.getDeviceLogsInFile(activity, true)
val intent = Intent(Intent.ACTION_SEND)

View File

@ -31,6 +31,7 @@ class LoginActivity : AppCompatActivity(), CoroutineScope {
private val app: App by lazy { applicationContext as App }
private lateinit var b: LoginActivityBinding
lateinit var navOptions: NavOptions
lateinit var navOptionsBuilder: NavOptions.Builder
val nav by lazy { Navigation.findNavController(this, R.id.nav_host_fragment) }
val errorSnackbar: ErrorSnackbar by lazy { ErrorSnackbar(this) }
val swipeRefreshLayout: SwipeRefreshLayoutNoTouch by lazy { b.swipeRefreshLayout }
@ -87,12 +88,12 @@ class LoginActivity : AppCompatActivity(), CoroutineScope {
super.onCreate(savedInstanceState)
setTheme(R.style.AppTheme_Light)
navOptions = NavOptions.Builder()
navOptionsBuilder = NavOptions.Builder()
.setEnterAnim(R.anim.slide_in_right)
.setExitAnim(R.anim.slide_out_left)
.setPopEnterAnim(R.anim.slide_in_left)
.setPopExitAnim(R.anim.slide_out_right)
.build()
navOptions = navOptionsBuilder.build()
b = LoginActivityBinding.inflate(layoutInflater)
setContentView(b.root)

View File

@ -23,8 +23,8 @@ import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.*
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.databinding.LoginChooserFragmentBinding
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.ui.dialogs.sync.RegisterUnavailableDialog
@ -51,7 +51,8 @@ class LoginChooserFragment : Fragment(), CoroutineScope {
private val job: Job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
private val manager
get() = app.permissionManager
// local/private variables go here
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
@ -67,6 +68,9 @@ class LoginChooserFragment : Fragment(), CoroutineScope {
if (!isAdded) return
val adapter = LoginChooserAdapter(activity, this::onLoginModeClicked)
if (!manager.isNotificationPermissionGranted) {
manager.requestNotificationsPermission(activity, 0, false){}
}
b.versionText.setText(
R.string.login_chooser_version_format,

View File

@ -14,6 +14,8 @@ import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.viewbinding.ViewBinding
import com.google.android.material.textfield.TextInputLayout
import com.mikepenz.iconics.IconicsDrawable
@ -33,7 +35,6 @@ import pl.szczodrzynski.edziennik.ui.login.LoginInfo.BaseCredential
import pl.szczodrzynski.edziennik.ui.login.LoginInfo.FormCheckbox
import pl.szczodrzynski.edziennik.ui.login.LoginInfo.FormField
import pl.szczodrzynski.navlib.colorAttr
import java.util.*
import kotlin.coroutines.CoroutineContext
class LoginFormFragment : Fragment(), CoroutineScope {
@ -63,8 +64,10 @@ class LoginFormFragment : Fragment(), CoroutineScope {
get() = arguments?.getString("platformDescription")
private val platformFormFields
get() = arguments?.getString("platformFormFields")?.split(";")
private val platformRealmData
get() = arguments?.getString("platformRealmData")?.toJsonObject()
private val platformData
get() = arguments?.getString("platformData")?.toJsonObject()
private val platformStoreKey
get() = arguments?.getString("platformStoreKey")
override fun onCreateView(
inflater: LayoutInflater,
@ -90,6 +93,11 @@ class LoginFormFragment : Fragment(), CoroutineScope {
val loginMode = arguments?.getInt("loginMode") ?: return
val mode = register.loginModes.firstOrNull { it.loginMode == loginMode } ?: return
if (mode.credentials.isEmpty()) {
login(loginType, loginMode)
return
}
b.title.setText(R.string.login_form_title_format, app.getString(register.registerName))
b.subTitle.text = platformName ?: app.getString(mode.name)
b.text.text = platformGuideText ?: app.getString(mode.guideText)
@ -156,12 +164,12 @@ class LoginFormFragment : Fragment(), CoroutineScope {
}
if (credential.qrDecoderClass != null) {
b.textLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
b.textLayout.endIconDrawable = IconicsDrawable(activity).apply {
icon = CommunityMaterial.Icon3.cmd_qrcode
sizeDp = 24
colorAttr(activity, R.attr.colorOnBackground)
}
b.textLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
b.textLayout.setEndIconOnClickListener {
scanQrCode(credential)
}
@ -250,7 +258,10 @@ class LoginFormFragment : Fragment(), CoroutineScope {
payload.putBoolean("fakeLogin", true)
}
payload.putBundle("webRealmData", platformRealmData?.toBundle())
if (platformStoreKey == null)
payload.putAll(platformData?.toBundle() ?: Bundle())
else
payload.putBundle(platformStoreKey, platformData?.toBundle())
var hasErrors = false
credentials.forEach { (credential, b) ->
@ -295,6 +306,14 @@ class LoginFormFragment : Fragment(), CoroutineScope {
if (hasErrors)
return
nav.navigate(R.id.loginProgressFragment, payload, activity.navOptions)
val navOptions =
if (credentials.isEmpty())
activity.navOptionsBuilder
.setPopUpTo(R.id.loginPlatformListFragment, inclusive = false)
.build()
else
activity.navOptions
nav.navigate(R.id.loginProgressFragment, payload, navOptions)
}
}

View File

@ -6,6 +6,7 @@ package pl.szczodrzynski.edziennik.ui.login
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.google.gson.JsonObject
import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import pl.szczodrzynski.edziennik.R
@ -64,7 +65,6 @@ object LoginInfo {
errorCodes = mapOf(
ERROR_LOGIN_LIBRUS_PORTAL_NOT_ACTIVATED to R.string.login_error_account_not_activated,
ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN to R.string.login_error_incorrect_login_or_password,
ERROR_CAPTCHA_LIBRUS_PORTAL to R.string.error_3001_reason
)
),
/*Mode(
@ -313,7 +313,24 @@ object LoginInfo {
errorCodes = mapOf()
)
)
)
),
Register(
loginType = LOGIN_TYPE_USOS,
internalName = "usos",
registerName = R.string.login_type_usos,
registerLogo = R.drawable.login_logo_usos,
loginModes = listOf(
Mode(
loginMode = LOGIN_MODE_USOS_OAUTH,
name = R.string.login_mode_usos_oauth,
icon = R.drawable.login_mode_usos_api,
guideText = R.string.login_mode_usos_oauth_guide,
isPlatformSelection = true,
credentials = listOf(),
errorCodes = mapOf(),
),
),
),
)
}
@ -357,7 +374,8 @@ object LoginInfo {
val icon: String,
val screenshot: String?,
val formFields: List<String>,
val realmData: RealmData
val data: JsonObject,
val storeKey: String?,
)
open class BaseCredential(

View File

@ -68,7 +68,8 @@ class LoginPlatformListFragment : Fragment(), CoroutineScope {
"platformName" to platform.name,
"platformDescription" to platform.description,
"platformFormFields" to platform.formFields.joinToString(";"),
"platformRealmData" to app.gson.toJson(platform.realmData)
"platformData" to platform.data.toString(),
"platformStoreKey" to platform.storeKey,
), activity.navOptions)
}

View File

@ -19,7 +19,7 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.ERROR_CAPTCHA_NEEDED
import pl.szczodrzynski.edziennik.data.api.ERROR_REQUIRES_USER_ACTION
import pl.szczodrzynski.edziennik.data.api.LOGIN_NO_ARGUMENTS
import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.api.events.ApiTaskErrorEvent
@ -29,6 +29,7 @@ import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
import pl.szczodrzynski.edziennik.databinding.LoginProgressFragmentBinding
import pl.szczodrzynski.edziennik.ext.joinNotNullStrings
import pl.szczodrzynski.edziennik.utils.managers.UserActionManager
import kotlin.coroutines.CoroutineContext
import kotlin.math.max
@ -137,14 +138,21 @@ class LoginProgressFragment : Fragment(), CoroutineScope {
return
}
app.userActionManager.execute(activity, event.profileId, event.type, onSuccess = { code ->
args.putString("recaptchaCode", code)
args.putLong("recaptchaTime", System.currentTimeMillis())
doFirstLogin(args)
}, onFailure = {
activity.error(ApiError(TAG, ERROR_CAPTCHA_NEEDED))
nav.navigateUp()
})
val callback = UserActionManager.UserActionCallback(
onSuccess = { data ->
args.putAll(data)
doFirstLogin(args)
},
onFailure = {
activity.error(ApiError(TAG, ERROR_REQUIRES_USER_ACTION))
nav.navigateUp()
},
onCancel = {
nav.navigateUp()
},
)
app.userActionManager.execute(activity, event, callback)
}
override fun onStart() {

View File

@ -0,0 +1,64 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-15.
*/
package pl.szczodrzynski.edziennik.ui.login.oauth
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.os.Bundle
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.utils.Utils.d
class OAuthLoginActivity : AppCompatActivity() {
companion object {
private const val TAG = "OAuthLoginActivity"
}
private var isSuccessful = false
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTitle(R.string.oauth_dialog_title)
val authorizeUrl = intent.getStringExtra("authorizeUrl") ?: return
val redirectUrl = intent.getStringExtra("redirectUrl") ?: return
val webView = WebView(this)
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
d(TAG, "Navigating to $url")
if (url.startsWith(redirectUrl)) {
isSuccessful = true
EventBus.getDefault().post(OAuthLoginResult(
isError = false,
responseUrl = url,
))
finish()
}
}
}
webView.settings.javaScriptEnabled = true
webView.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setContentView(webView)
webView.loadUrl(authorizeUrl)
}
override fun onDestroy() {
super.onDestroy()
if (!isSuccessful)
EventBus.getDefault().post(OAuthLoginResult(
isError = false,
responseUrl = null,
))
}
}

View File

@ -0,0 +1,10 @@
/*
* Copyright (c) Kuba Szczodrzyński 2022-10-15.
*/
package pl.szczodrzynski.edziennik.ui.login.oauth
data class OAuthLoginResult(
val isError: Boolean,
val responseUrl: String?,
)

View File

@ -54,6 +54,13 @@ class SettingsRegisterCard(util: SettingsUtil) : SettingsCard(util) {
}
override fun getItems() = listOfNotNull(
util.createActionItem(
text = R.string.menu_timetable_config,
icon = CommunityMaterial.Icon3.cmd_timetable
) {
TimetableConfigDialog(activity, reloadOnDismiss = false).show()
},
util.createActionItem(
text = R.string.menu_agenda_config,
icon = CommunityMaterial.Icon.cmd_calendar_outline
@ -70,7 +77,7 @@ class SettingsRegisterCard(util: SettingsUtil) : SettingsCard(util) {
util.createActionItem(
text = R.string.menu_messages_config,
icon = CommunityMaterial.Icon.cmd_calendar_outline
icon = CommunityMaterial.Icon.cmd_email_outline
) {
MessagesConfigDialog(activity, reloadOnDismiss = false).show()
},

View File

@ -4,6 +4,7 @@
package pl.szczodrzynski.edziennik.ui.timetable
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -12,6 +13,7 @@ import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.asynclayoutinflater.view.AsyncLayoutInflater
import androidx.core.graphics.ColorUtils
import androidx.core.view.*
import com.linkedin.android.tachyon.DayView
import com.linkedin.android.tachyon.DayViewConfig
@ -19,8 +21,11 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.*
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.MainActivity.Companion.DRAWER_ITEM_TIMETABLE
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_USOS
import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.db.entity.Lesson
import pl.szczodrzynski.edziennik.data.db.full.AttendanceFull
@ -33,11 +38,13 @@ import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.ui.base.lazypager.LazyFragment
import pl.szczodrzynski.edziennik.ui.timetable.TimetableFragment.Companion.DEFAULT_END_HOUR
import pl.szczodrzynski.edziennik.ui.timetable.TimetableFragment.Companion.DEFAULT_START_HOUR
import pl.szczodrzynski.edziennik.utils.Colors
import pl.szczodrzynski.edziennik.utils.managers.NoteManager
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.Time
import java.util.*
import pl.szczodrzynski.edziennik.utils.mutableLazy
import kotlin.coroutines.CoroutineContext
import kotlin.math.max
import kotlin.math.min
class TimetableDayFragment : LazyFragment(), CoroutineScope {
@ -71,13 +78,14 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
// find SwipeRefreshLayout in the hierarchy
private val refreshLayout by lazy { view?.findParentById(R.id.refreshLayout) }
private val profileConfig by lazy { app.config.forProfile().ui }
private val dayView by lazy {
private val dayViewDelegate = mutableLazy {
val dayView = DayView(activity, DayViewConfig(
startHour = startHour,
endHour = endHour,
dividerHeight = 1.dp,
halfHourHeight = 60.dp,
halfHourHeight = if (app.profile.loginStoreType == LOGIN_TYPE_USOS) 45.dp else 60.dp,
hourDividerColor = R.attr.hourDividerColor.resolveAttr(context),
halfHourDividerColor = R.attr.halfHourDividerColor.resolveAttr(context),
hourLabelWidth = 40.dp,
@ -85,8 +93,9 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
eventMargin = 2.dp
), true)
dayView.setPadding(10.dp)
return@lazy dayView
return@mutableLazy dayView
}
private val dayView by dayViewDelegate
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null
@ -173,8 +182,26 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
return
}
if (dayViewDelegate.isInitialized())
b.dayFrame.removeView(dayView)
val lessonsActual = lessons.filter { it.type != Lesson.TYPE_NO_LESSONS }
val minStartHour = lessonsActual.minOf { it.displayStartTime?.hour ?: DEFAULT_END_HOUR }
val maxEndHour = lessonsActual.maxOf { it.displayEndTime?.hour?.plus(1) ?: DEFAULT_START_HOUR }
if (profileConfig.timetableTrimHourRange) {
dayViewDelegate.deinitialize()
// end/start defaults are swapped on purpose
startHour = minStartHour
endHour = maxEndHour
} else if (startHour > minStartHour || endHour < maxEndHour) {
dayViewDelegate.deinitialize()
startHour = min(startHour, minStartHour)
endHour = max(endHour, maxEndHour)
}
b.scrollView.isVisible = true
b.dayFrame.removeView(dayView)
b.dayFrame.addView(dayView, 0)
// Inflate a label view for each hour the day view will display
@ -195,7 +222,7 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
lessons.forEach { it.showAsUnseen = !it.seen }
buildLessonViews(lessons.filter { it.type != Lesson.TYPE_NO_LESSONS }, events, attendanceList)
buildLessonViews(lessonsActual, events, attendanceList)
}
private fun buildLessonViews(lessons: List<LessonFull>, events: List<EventFull>, attendanceList: List<AttendanceFull>) {
@ -215,7 +242,10 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
val colorSecondary = android.R.attr.textColorSecondary.resolveAttr(activity)
for (lesson in lessons) {
val attendance = attendanceList.find { it.startTime == lesson.startTime }
val attendance = if (profileConfig.timetableShowAttendance)
attendanceList.find { it.startTime == lesson.startTime }
else
null
val startTime = lesson.displayStartTime ?: continue
val endTime = lesson.displayEndTime ?: continue
@ -245,23 +275,20 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
}
}
val eventList = events.filter { it.time != null && it.time == lesson.displayStartTime }.take(3)
eventList.getOrNull(0).let {
lb.event1.visibility = if (it == null) View.GONE else View.VISIBLE
lb.event1.background = it?.let {
R.drawable.bg_circle.resolveDrawable(activity).setTintColor(it.eventColor)
val eventIcons = listOf(lb.event1, lb.event2, lb.event3)
if (profileConfig.timetableShowEvents) {
val eventList = events.filter { it.time != null && it.time == lesson.displayStartTime }.take(3)
for ((i, eventIcon) in eventIcons.withIndex()) {
eventList.getOrNull(i).let {
eventIcon.isVisible = it != null
eventIcon.background = it?.let {
R.drawable.bg_circle.resolveDrawable(activity).setTintColor(it.eventColor)
}
}
}
}
eventList.getOrNull(1).let {
lb.event2.visibility = if (it == null) View.GONE else View.VISIBLE
lb.event2.background = it?.let {
R.drawable.bg_circle.resolveDrawable(activity).setTintColor(it.eventColor)
}
}
eventList.getOrNull(2).let {
lb.event3.visibility = if (it == null) View.GONE else View.VISIBLE
lb.event3.background = it?.let {
R.drawable.bg_circle.resolveDrawable(activity).setTintColor(it.eventColor)
} else {
for (eventIcon in eventIcons) {
eventIcon.visibility = View.GONE
}
}
@ -295,13 +322,36 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
lesson.classroom?.let { add(it) }
}.concat(arrowRight)
lb.annotationVisible = manager.getAnnotation(activity, lesson, lb.annotation)
lb.lessonNumber = lesson.displayLessonNumber
val lessonText =
lesson.getNoteSubstituteText(showNotes = true) ?: lesson.displaySubjectName
val (subjectTextPrimary, subjectTextSecondary) = if (profileConfig.timetableColorSubjectName) {
val subjectColor = lesson.color ?: Colors.stringToMaterialColorCRC(lessonText?.toString() ?: "")
if (lb.annotationVisible) {
lb.subjectContainer.background = ColorDrawable(subjectColor)
} else {
lb.subjectContainer.setBackgroundResource(R.drawable.timetable_subject_color_rounded)
lb.subjectContainer.background.setTintColor(subjectColor)
}
when (ColorUtils.calculateLuminance(subjectColor) > 0.5) {
true -> /* light */ 0xFF000000 to 0xFF666666
false -> /* dark */ 0xFFFFFFFF to 0xFFAAAAAA
}
} else {
lb.subjectContainer.background = null
null to colorSecondary
}
lb.lessonNumber = lesson.displayLessonNumber
if (subjectTextPrimary != null)
lb.lessonNumberText.setTextColor(subjectTextPrimary.toInt())
lb.subjectName.text = lessonText?.let {
if (lesson.type == Lesson.TYPE_CANCELLED || lesson.type == Lesson.TYPE_SHIFTED_SOURCE)
it.asStrikethroughSpannable().asColoredSpannable(colorSecondary)
it.asStrikethroughSpannable().asColoredSpannable(subjectTextSecondary.toInt())
else if (subjectTextPrimary != null)
it.asColoredSpannable(subjectTextPrimary.toInt())
else
it
}
@ -328,7 +378,6 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
}
//lb.subjectName.typeface = Typeface.create("sans-serif-light", Typeface.BOLD)
lb.annotationVisible = manager.getAnnotation(activity, lesson, lb.annotation)
val lessonNumberMargin =
if (lb.annotationVisible) (-8).dp
else 0
@ -338,9 +387,8 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
// The day view needs the event time ranges in the start minute/end minute format,
// so calculate those here
val startMinute = 60 * (lesson.displayStartTime?.hour
?: 0) + (lesson.displayStartTime?.minute ?: 0)
val endMinute = startMinute + 45
val startMinute = 60 * startTime.hour + startTime.minute
val endMinute = 60 * endTime.hour + endTime.minute
eventTimeRanges.add(DayView.EventTimeRange(startMinute, endMinute))
}

View File

@ -26,6 +26,7 @@ import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.db.entity.Metadata
import pl.szczodrzynski.edziennik.databinding.FragmentTimetableV2Binding
import pl.szczodrzynski.edziennik.ext.getSchoolYearConstrains
import pl.szczodrzynski.edziennik.ui.dialogs.settings.TimetableConfigDialog
import pl.szczodrzynski.edziennik.ui.event.EventManualDialog
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetPrimaryItem
@ -52,6 +53,7 @@ class TimetableFragment : Fragment(), CoroutineScope {
private var fabShown = false
private val items = mutableListOf<Date>()
private val profileConfig by lazy { app.config.forProfile().ui }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null
@ -128,8 +130,8 @@ class TimetableFragment : Fragment(), CoroutineScope {
}
val lessonRanges = app.db.lessonRangeDao().getAllNow(App.profileId)
startHour = lessonRanges.map { it.startTime.hour }.minOrNull() ?: DEFAULT_START_HOUR
endHour = lessonRanges.map { it.endTime.hour }.maxOrNull()?.plus(1) ?: DEFAULT_END_HOUR
startHour = lessonRanges.minOfOrNull { it.startTime.hour } ?: DEFAULT_START_HOUR
endHour = lessonRanges.maxOfOrNull { it.endTime.hour }?.plus(1) ?: DEFAULT_END_HOUR
}
deferred.await()
if (!isAdded)
@ -208,6 +210,13 @@ class TimetableFragment : Fragment(), CoroutineScope {
activity.bottomSheet.close()
GenerateBlockTimetableDialog(activity)
}),
BottomSheetPrimaryItem(true)
.withTitle(R.string.menu_timetable_config)
.withIcon(CommunityMaterial.Icon.cmd_cog_outline)
.withOnClickListener {
activity.bottomSheet.close()
TimetableConfigDialog(activity, false, null, null).show()
},
BottomSheetSeparatorItem(true),
BottomSheetPrimaryItem(true)
.withTitle(R.string.menu_mark_as_read)

View File

@ -15,6 +15,7 @@ import androidx.core.graphics.ColorUtils;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Random;
import java.util.zip.CRC32;
import pl.szczodrzynski.edziennik.data.db.entity.Grade;
@ -84,6 +85,28 @@ public class Colors {
0xFF6D4C41
};
public static final int[] materialColorsBasic = {
0xFFE53935, 0xFFD81B60, 0xFF8E24AA, 0xFF5E35B1,
0xFF3949AB, 0xFF1E88E5, 0xFF039BE5, 0xFF00ACC1,
0xFF00897B, 0xFF43A047, 0xFF7CB342, 0xFFC0CA33,
0xFFFDD835, 0xFFFFB300, 0xFFFB8C00, 0xFFF4511E,
0xFF6D4C41, 0xFF757575, 0xFF546E7A, 0xFF00E676,
0xFFEF9A9A, 0xFFF48FB1, 0xFFCE93D8, 0xFFB39DDB,
0xFF9FA8DA, 0xFF90CAF9, 0xFF81D4FA, 0xFF80DEEA,
0xFF80CBC4, 0xFFA5D6A7, 0xFFC5E1A5, 0xFFE6EE9C,
0xFFFFF59D, 0xFFFFE082, 0xFFFFCC80, 0xFFFFAB91,
0xFFBCAAA4, 0xFFEEEEEE, 0xFFB0BEC5, 0xFFCCFF90,
};
public static final int[] metroColors = {
0xFF76FF03, 0xFF60A917, 0xFF00C853, 0xFF00ABA9,
0xFF1BA1E2, 0xFF0050EF, 0xFF6A00FF, 0xFFAA00FF,
0xFFF472D0, 0xFFD80073, 0xFFA20025, 0xFFE51400,
0xFFFA6800, 0xFFF0A30A, 0xFFE3C800, 0xFF795548,
0xFF6D8764, 0xFF647687, 0xFF76608A, 0xFFA0522D,
};
/**
* Used for teacher's images (e.g. in messages or announcements).
* @param s teacher's fullName
@ -115,6 +138,18 @@ public class Colors {
return materialColors[getRandomNumberInRange(0, materialColors.length-1, seed)];
}
public static int stringToMaterialColorCRC(String s) {
long seed;
try {
CRC32 crc = new CRC32();
crc.update(s.getBytes());
seed = crc.getValue();
} catch (Exception e) {
seed = 1234;
}
return metroColors[(int) (seed % metroColors.length)];
}
public static int gradeToColor(Grade grade)
{
if (grade.getType() == Grade.TYPE_POINT_SUM) {

View File

@ -19,7 +19,6 @@ class MutableLazyImpl<T>(initializer: () -> T, lock: Any? = null) {
return synchronized(lock) {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
@ -29,7 +28,11 @@ class MutableLazyImpl<T>(initializer: () -> T, lock: Any? = null) {
fun isInitialized() = _value !== UNINITIALIZED_VALUE
fun deinitialize() {
_value = UNINITIALIZED_VALUE
}
override fun toString() = if (isInitialized()) _value.toString() else "ChangeableLazy value not initialized yet."
}
fun <T> mutableLazy(initializer: () -> T): MutableLazyImpl<T> = MutableLazyImpl(initializer)
fun <T> mutableLazy(initializer: () -> T): MutableLazyImpl<T> = MutableLazyImpl(initializer)

View File

@ -36,7 +36,13 @@ class PermissionManager(val app: App) : CoroutineScope {
app.checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED
else
true
val isNotificationPermissionGranted by lazy {
if (Build.VERSION.SDK_INT >= 33) {
app.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}
private fun openPermissionSettings(activity: AppCompatActivity) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
val uri = Uri.fromParts("package", app.packageName, null)
@ -80,6 +86,10 @@ class PermissionManager(val app: App) : CoroutineScope {
.show()
}
result.hasPermanentDenied() -> {
if (!isRequired) {
onSuccess()
return@launch
}
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.permissions_required)
.setMessage(R.string.permissions_denied)
@ -92,6 +102,18 @@ class PermissionManager(val app: App) : CoroutineScope {
}
}
}
fun requestNotificationsPermission(
activity: AppCompatActivity,
@StringRes permissionMessage: Int,
isRequired: Boolean = false,
onSuccess: suspend CoroutineScope.() -> Unit
) = requestPermission(
activity,
permissionMessage,
isRequired,
Manifest.permission.POST_NOTIFICATIONS,
onSuccess
)
fun requestStoragePermission(
activity: AppCompatActivity,

View File

@ -7,58 +7,53 @@ package pl.szczodrzynski.edziennik.utils.managers
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationCompat
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.ERROR_CAPTCHA_LIBRUS_PORTAL
import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.Intent
import pl.szczodrzynski.edziennik.ext.JsonObject
import pl.szczodrzynski.edziennik.ext.pendingIntentFlag
import pl.szczodrzynski.edziennik.ui.captcha.LibrusCaptchaDialog
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.ui.captcha.RecaptchaPromptDialog
import pl.szczodrzynski.edziennik.ui.login.oauth.OAuthLoginActivity
import pl.szczodrzynski.edziennik.ui.login.oauth.OAuthLoginResult
import pl.szczodrzynski.edziennik.utils.Utils.d
class UserActionManager(val app: App) {
companion object {
private const val TAG = "UserActionManager"
}
fun requiresUserAction(apiError: ApiError): Boolean {
return apiError.errorCode == ERROR_CAPTCHA_LIBRUS_PORTAL
}
fun sendToUser(apiError: ApiError) {
val type = when (apiError.errorCode) {
ERROR_CAPTCHA_LIBRUS_PORTAL -> UserActionRequiredEvent.CAPTCHA_LIBRUS
else -> 0
}
fun sendToUser(event: UserActionRequiredEvent) {
if (EventBus.getDefault().hasSubscriberForEvent(UserActionRequiredEvent::class.java)) {
EventBus.getDefault().post(UserActionRequiredEvent(apiError.profileId ?: -1, type))
EventBus.getDefault().post(event)
return
}
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val text = app.getString(when (type) {
UserActionRequiredEvent.CAPTCHA_LIBRUS -> R.string.notification_user_action_required_captcha_librus
else -> R.string.notification_user_action_required_text
}, apiError.profileId)
val text = app.getString(event.errorText, event.profileId)
val intent = Intent(
app,
MainActivity::class.java,
"action" to "userActionRequired",
"profileId" to (apiError.profileId ?: -1),
"type" to type
app,
MainActivity::class.java,
"action" to "userActionRequired",
"profileId" to event.profileId,
"type" to event.type,
"params" to event.params,
)
val pendingIntent = PendingIntent.getActivity(
app,
System.currentTimeMillis().toInt(),
intent,
PendingIntent.FLAG_ONE_SHOT or pendingIntentFlag(),
)
val pendingIntent = PendingIntent.getActivity(app, System.currentTimeMillis().toInt(), intent, PendingIntent.FLAG_ONE_SHOT or pendingIntentFlag())
val notification = NotificationCompat.Builder(app, app.notificationChannelsManager.userAttention.key)
val notification =
NotificationCompat.Builder(app, app.notificationChannelsManager.userAttention.key)
.setContentTitle(app.getString(R.string.notification_user_action_required_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_error_outline)
@ -74,29 +69,98 @@ class UserActionManager(val app: App) {
manager.notify(System.currentTimeMillis().toInt(), notification)
}
fun execute(
activity: AppCompatActivity,
profileId: Int?,
type: Int,
onSuccess: ((code: String) -> Unit)? = null,
onFailure: (() -> Unit)? = null
) {
if (type != UserActionRequiredEvent.CAPTCHA_LIBRUS)
return
class UserActionCallback(
val onSuccess: ((data: Bundle) -> Unit)? = null,
val onFailure: (() -> Unit)? = null,
val onCancel: (() -> Unit)? = null,
)
if (profileId == null)
return
// show captcha dialog
// use passed onSuccess listener, else sync profile
LibrusCaptchaDialog(
fun execute(
activity: AppCompatActivity,
event: UserActionRequiredEvent,
callback: UserActionCallback,
) {
d(TAG, "Running user action (${event.type}) with params: ${event.params}")
val isSuccessful = when (event.type) {
UserActionRequiredEvent.Type.RECAPTCHA -> executeRecaptcha(activity, event, callback)
UserActionRequiredEvent.Type.OAUTH -> executeOauth(activity, event, callback)
}
if (!isSuccessful)
callback.onFailure?.invoke()
}
private fun executeRecaptcha(
activity: AppCompatActivity,
event: UserActionRequiredEvent,
callback: UserActionCallback,
): Boolean {
val siteKey = event.params.getString("siteKey") ?: return false
val referer = event.params.getString("referer") ?: return false
RecaptchaPromptDialog(
activity = activity,
onSuccess = onSuccess ?: { code ->
EdziennikTask.syncProfile(profileId, arguments = JsonObject(
siteKey = siteKey,
referer = referer,
onSuccess = { code ->
finishAction(activity, event, callback, Bundle(
"recaptchaCode" to code,
"recaptchaTime" to System.currentTimeMillis()
)).enqueue(activity)
"recaptchaTime" to System.currentTimeMillis(),
))
},
onFailure = onFailure
onCancel = callback.onCancel,
).show()
return true
}
private fun executeOauth(
activity: AppCompatActivity,
event: UserActionRequiredEvent,
callback: UserActionCallback,
): Boolean {
val storeKey = event.params.getString("responseStoreKey") ?: return false
event.params.getString("authorizeUrl") ?: return false
event.params.getString("redirectUrl") ?: return false
var listener: Any? = null
listener = object {
@Subscribe(threadMode = ThreadMode.MAIN)
fun onOAuthLoginResult(result: OAuthLoginResult) {
EventBus.getDefault().unregister(listener)
when {
result.isError -> callback.onFailure?.invoke()
result.responseUrl != null -> {
finishAction(activity, event, callback, Bundle(
storeKey to result.responseUrl,
))
}
else -> callback.onCancel?.invoke()
}
}
}
EventBus.getDefault().register(listener)
val intent = Intent(activity, OAuthLoginActivity::class.java).putExtras(event.params)
activity.startActivity(intent)
return true
}
private fun finishAction(
activity: AppCompatActivity,
event: UserActionRequiredEvent,
callback: UserActionCallback,
data: Bundle,
) {
val extras = event.params.getBundle("extras")
if (extras != null)
data.putAll(extras)
if (callback.onSuccess != null)
callback.onSuccess.invoke(data)
else if (event.profileId != null)
EdziennikTask.syncProfile(
profileId = event.profileId,
arguments = data.toJsonObject(),
).enqueue(activity)
else
callback.onFailure?.invoke()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@ -25,13 +25,6 @@
android:layout_height="wrap_content"
android:text="Save Debug Logs" />
<com.google.android.material.button.MaterialButton
android:id="@+id/librusCaptchaButton"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="LIBRUS® Captcha" />
<com.google.android.material.button.MaterialButton
android:id="@+id/refreshWidget"
style="@style/Widget.MaterialComponents.Button"

View File

@ -31,7 +31,7 @@
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/agenda_config_appearance"
android:text="@string/config_appearance"
android:textAppearance="@style/NavView.TextView.Subtitle" />
<com.google.android.material.checkbox.MaterialCheckBox

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) Kuba Szczodrzyński 2022-10-7.
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="config"
type="pl.szczodrzynski.edziennik.config.ProfileConfigUI" />
</data>
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/config_appearance"
android:textAppearance="@style/NavView.TextView.Subtitle" />
<com.google.android.material.checkbox.MaterialCheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="@={config.timetableShowEvents}"
android:minHeight="32dp"
android:text="@string/timetable_config_show_events" />
<com.google.android.material.checkbox.MaterialCheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="@={config.timetableShowAttendance}"
android:minHeight="32dp"
android:text="@string/timetable_config_show_attendance" />
<com.google.android.material.checkbox.MaterialCheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="@={config.timetableColorSubjectName}"
android:minHeight="32dp"
android:text="@string/timetable_config_color_subject_name" />
<com.google.android.material.checkbox.MaterialCheckBox
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:checked="@={config.timetableTrimHourRange}"
android:minHeight="32dp"
android:text="@string/timetable_config_trim_hour_range" />
</LinearLayout>
</ScrollView>
</layout>

View File

@ -45,14 +45,15 @@
tools:visibility="gone" />
<LinearLayout
android:id="@+id/subjectContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:baselineAligned="false"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp">
<!--tools:background="@drawable/timetable_subject_color_rounded"-->
android:paddingVertical="4dp"
tools:background="@drawable/timetable_subject_color_rounded">
<com.mikepenz.iconics.view.IconicsTextView
android:id="@+id/subjectName"

View File

@ -1383,7 +1383,7 @@
<string name="notes_type_grade">Grade</string>
<string name="privacy_policy_dialog_html"><![CDATA[By using the application, you confirm that you have <a href="https://szkolny.eu/privacy-policy">read the Privacy Policy</a> and accept its provisions.<br /><br />The authors of the application are not responsible for the use of the Szkolny.eu application.]]></string>
<string name="login_chooser_version_format">Szkolny.eu v%s\n%s</string>
<string name="agenda_config_appearance">Appearance</string>
<string name="config_appearance">Appearance</string>
<string name="agenda_config_elearning">Online learning</string>
<string name="event_type_elearning">online lesson</string>
<string name="menu_messages_config">Messages settings</string>

View File

@ -30,11 +30,7 @@
<string name="error_111" translatable="false">ERROR_LOGIN_METHOD_NOT_SATISFIED</string>
<string name="error_112" translatable="false">ERROR_NOT_IMPLEMENTED</string>
<string name="error_113" translatable="false">ERROR_FILE_DOWNLOAD</string>
<string name="error_115" translatable="false">ERROR_NO_STUDENTS_IN_ACCOUNT</string>
<string name="error_3000" translatable="false">ERROR_CAPTCHA_NEEDED</string>
<string name="error_3001" translatable="false">ERROR_CAPTCHA_LIBRUS_PORTAL</string>
<string name="error_114" translatable="false">ERROR_REQUIRES_USER_ACTION</string>
<string name="error_5000" translatable="false">ERROR_API_PDO_ERROR</string>
<string name="error_5001" translatable="false">ERROR_API_INVALID_CLIENT</string>
@ -174,6 +170,12 @@
<string name="error_631" translatable="false">ERROR_PODLASIE_API_OTHER</string>
<string name="error_632" translatable="false">ERROR_PODLASIE_API_DATA_MISSING</string>
<string name="error_702" translatable="false">ERROR_USOS_OAUTH_GOT_DIFFERENT_TOKEN</string>
<string name="error_703" translatable="false">ERROR_USOS_OAUTH_INCOMPLETE_RESPONSE</string>
<string name="error_704" translatable="false">ERROR_USOS_NO_STUDENT_PROGRAMMES</string>
<string name="error_705" translatable="false">ERROR_USOS_API_INCOMPLETE_RESPONSE</string>
<string name="error_706" translatable="false">ERROR_USOS_API_MISSING_RESPONSE</string>
<string name="error_801" translatable="false">ERROR_TEMPLATE_WEB_OTHER</string>
<string name="error_900" translatable="false">EXCEPTION_API_TASK</string>
@ -220,11 +222,7 @@
<string name="error_111_reason">Nie można wywołać metody logowania. Skontaktuj się z twórcą aplikacji.</string>
<string name="error_112_reason">Nie zaimplementowano</string>
<string name="error_113_reason">Wystąpił błąd podczas pobierania pliku. Dziennik może być przeciążony lub mieć przerwę techniczną.</string>
<string name="error_115_reason">Brak uczniów przypisanych do konta</string>
<string name="error_3000_reason">Wymagane rozwiązanie zadania Captcha</string>
<string name="error_3001_reason">LIBRUS®: wymagane rozwiązanie zadania Captcha</string>
<string name="error_114_reason">Wymagana akcja w aplikacji</string>
<string name="error_5000_reason">ERROR_API_PDO_ERROR</string>
<string name="error_5001_reason">Nieprawidłowy ID klienta API</string>
@ -364,6 +362,12 @@
<string name="error_631_reason">ERROR_PODLASIE_API_OTHER</string>
<string name="error_632_reason">Brak danych. Zgłoś błąd programiście.</string>
<string name="error_702_reason">Błąd logowania: otrzymano nieprawidłowy token</string>
<string name="error_703_reason">Błąd logowania: niekompletna odpowiedź serwera</string>
<string name="error_704_reason">Student nie jest zapisany na żaden kierunek</string>
<string name="error_705_reason">Brakujące dane w odpowiedzi serwera</string>
<string name="error_706_reason">Brakująca odpowiedź serwera</string>
<string name="error_801_reason">ERROR_TEMPLATE_WEB_OTHER</string>
<string name="error_900_reason">Błąd synchronizacji. Upewnij się, że masz połączenie z internetem, a następnie zgłoś błąd.</string>

View File

@ -1424,7 +1424,7 @@
<string name="privacy_policy_dialog_html"><![CDATA[Korzystając z aplikacji potwierdzasz <a href="https://szkolny.eu/privacy-policy">przeczytanie Polityki prywatności</a> i akceptujesz jej postanowienia.<br /><br />Autorzy aplikacji nie biorą odpowiedzialności za korzystanie z aplikacji Szkolny.eu.]]></string>
<string name="login_chooser_version_format">Szkolny.eu v%s\n%s</string>
<string name="menu_agenda_config">Ustawienia terminarza</string>
<string name="agenda_config_appearance">Wygląd</string>
<string name="config_appearance">Wygląd</string>
<string name="agenda_config_lesson_changes">Pokazuj zmiany planu lekcji</string>
<string name="agenda_config_teacher_absence">Pokazuj nieobecności nauczycieli</string>
<string name="agenda_config_compact_mode">Tryb kompaktowy</string>
@ -1537,4 +1537,15 @@
<string name="login_summary_account_parent">(rodzic)</string>
<string name="grades_subject_unknown">- nieznany przedmiot -</string>
<string name="grades_subject_unknown_help">{cmd-information-outline} Oceny, których przedmiot nie został podany w dzienniku. Może to być na przykład taki, który nie jest prowadzony w tym roku szkolnym.</string>
<string name="menu_timetable_config">Ustawienia planu lekcji</string>
<string name="timetable_config_show_events">Pokazuj wydarzenia przy lekcjach</string>
<string name="timetable_config_show_attendance">Pokazuj rodzaj obecności na lekcji</string>
<string name="timetable_config_color_subject_name">Koloruj nazwę przedmiotu</string>
<string name="timetable_config_trim_hour_range">Nie pokazuj godzin bez lekcji</string>
<string name="edziennik_progress_login_usos_api">Logowanie do USOS API...</string>
<string name="login_type_usos">USOS</string>
<string name="login_mode_usos_oauth">Logowanie z użyciem przeglądarki</string>
<string name="login_mode_usos_oauth_guide">TODO</string>
<string name="notification_user_action_required_oauth_usos">USOS - wymagane logowanie z użyciem przeglądarki</string>
<string name="oauth_dialog_title">Zaloguj się</string>
</resources>

View File

@ -5,8 +5,8 @@ buildscript {
kotlin_version = '1.6.10'
release = [
versionName: "4.12",
versionCode: 4120099
versionName: "4.13-beta.2",
versionCode: 4130002
]
setup = [