Merge pull request #142 from szkolny-eu/feature/usos

[API] Add USOS API support.
This commit is contained in:
Kuba Szczodrzyński 2022-10-17 22:52:08 +02:00 committed by GitHub
commit 3a91f87ccd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 3940 additions and 159 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -157,6 +157,10 @@
android:configChanges="orientation|keyboardHidden" android:configChanges="orientation|keyboardHidden"
android:exported="false" android:exported="false"
android:theme="@style/Base.Theme.AppCompat" /> 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.base.BuildInvalidActivity" android:exported="false" />
<activity android:name=".ui.settings.contributors.ContributorsActivity" android:exported="false" /> <activity android:name=".ui.settings.contributors.ContributorsActivity" android:exported="false" />

View File

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

View File

@ -84,19 +84,21 @@ class ApiService : Service() {
runTask() runTask()
} }
override fun onRequiresUserAction(event: UserActionRequiredEvent) {
app.userActionManager.sendToUser(event)
taskRunning?.cancel()
clearTask()
runTask()
}
override fun onError(apiError: ApiError) { override fun onError(apiError: ApiError) {
lastEventTime = System.currentTimeMillis() lastEventTime = System.currentTimeMillis()
d(TAG, "Task $taskRunningId threw an error - $apiError") d(TAG, "Task $taskRunningId threw an error - $apiError")
apiError.profileId = taskProfileId apiError.profileId = taskProfileId
if (app.userActionManager.requiresUserAction(apiError)) { EventBus.getDefault().postSticky(ApiTaskErrorEvent(apiError))
app.userActionManager.sendToUser(apiError) errorList.add(apiError)
} apiError.throwable?.printStackTrace()
else {
EventBus.getDefault().postSticky(ApiTaskErrorEvent(apiError))
errorList.add(apiError)
apiError.throwable?.printStackTrace()
}
if (apiError.isCritical) { if (apiError.isCritical) {
taskRunning?.cancel() 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_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_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 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_URL = "https://cpdklaser.zeto.bialystok.pl/api"
const val PODLASIE_API_USER_ENDPOINT = "/pobierzDaneUcznia" const val PODLASIE_API_USER_ENDPOINT = "/pobierzDaneUcznia"
const val PODLASIE_API_LOGOUT_DEVICES_ENDPOINT = "/wyczyscUrzadzenia" 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_LOGIN_METHOD_NOT_SATISFIED = 111
const val ERROR_NOT_IMPLEMENTED = 112 const val ERROR_NOT_IMPLEMENTED = 112
const val ERROR_FILE_DOWNLOAD = 113 const val ERROR_FILE_DOWNLOAD = 113
const val ERROR_REQUIRES_USER_ACTION = 114
const val ERROR_NO_STUDENTS_IN_ACCOUNT = 115
const val ERROR_CAPTCHA_NEEDED = 3000
const val ERROR_CAPTCHA_LIBRUS_PORTAL = 3001
const val ERROR_API_PDO_ERROR = 5000 const val ERROR_API_PDO_ERROR = 5000
const val ERROR_API_INVALID_CLIENT = 5001 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_OTHER = 631
const val ERROR_PODLASIE_API_DATA_MISSING = 632 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 ERROR_TEMPLATE_WEB_OTHER = 801
const val EXCEPTION_API_TASK = 900 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.podlasie.login.PodlasieLoginApi
import pl.szczodrzynski.edziennik.data.api.edziennik.template.login.TemplateLoginApi 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.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.VulcanLoginHebe
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLoginWebMain import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLoginWebMain
import pl.szczodrzynski.edziennik.data.api.models.LoginMethod import pl.szczodrzynski.edziennik.data.api.models.LoginMethod
@ -127,6 +128,15 @@ val podlasieLoginMethods = listOf(
.withRequiredLoginMethod { _, _ -> LOGIN_METHOD_NOT_NEEDED } .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( val templateLoginMethods = listOf(
LoginMethod(LOGIN_TYPE_TEMPLATE, LOGIN_METHOD_TEMPLATE_WEB, TemplateLoginWeb::class.java) LoginMethod(LOGIN_TYPE_TEMPLATE, LOGIN_METHOD_TEMPLATE_WEB, TemplateLoginWeb::class.java)
.withIsPossible { _, _ -> true } .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.mobidziennik.Mobidziennik
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.Podlasie 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.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.edziennik.vulcan.Vulcan
import pl.szczodrzynski.edziennik.data.api.events.RegisterAvailabilityEvent import pl.szczodrzynski.edziennik.data.api.events.RegisterAvailabilityEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback 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_VULCAN -> Vulcan(app, profile, loginStore, taskCallback)
LOGIN_TYPE_PODLASIE -> Podlasie(app, profile, loginStore, taskCallback) LOGIN_TYPE_PODLASIE -> Podlasie(app, profile, loginStore, taskCallback)
LOGIN_TYPE_TEMPLATE -> Template(app, profile, loginStore, taskCallback) LOGIN_TYPE_TEMPLATE -> Template(app, profile, loginStore, taskCallback)
LOGIN_TYPE_USOS -> Usos(app, profile, loginStore, taskCallback)
else -> null else -> null
} }
if (edziennikInterface == 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.data.synergia.*
import pl.szczodrzynski.edziennik.data.api.edziennik.librus.firstlogin.LibrusFirstLogin 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.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.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError 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 { private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {
return object : EdziennikCallback { return object : EdziennikCallback {
override fun onCompleted() { callback.onCompleted() } override fun onCompleted() { callback.onCompleted() }
override fun onRequiresUserAction(event: UserActionRequiredEvent) { callback.onRequiresUserAction(event) }
override fun onProgress(step: Float) { callback.onProgress(step) } override fun onProgress(step: Float) { callback.onProgress(step) }
override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) } override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) }
override fun onError(apiError: ApiError) { 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.*
import pl.szczodrzynski.edziennik.data.api.* import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.librus.DataLibrus 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.data.api.models.ApiError
import pl.szczodrzynski.edziennik.ext.* import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.utils.Utils.d 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 val error = if (response.code() == 200) null else
json.getJsonArray("errors")?.getString(0) json.getJsonArray("errors")?.getString(0)
?: json.getJsonObject("errors")?.entrySet()?.firstOrNull()?.value?.asString ?: 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 -> error?.let { code ->
when { when {
code.contains("Sesja logowania wygasła") -> ERROR_LOGIN_LIBRUS_PORTAL_CSRF_EXPIRED code.contains("Sesja logowania wygasła") -> ERROR_LOGIN_LIBRUS_PORTAL_CSRF_EXPIRED
code.contains("Upewnij się, że nie") -> ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN 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 code.contains("Podany adres e-mail jest nieprawidłowy.") -> ERROR_LOGIN_LIBRUS_PORTAL_INVALID_LOGIN
else -> ERROR_LOGIN_LIBRUS_PORTAL_ACTION_ERROR else -> ERROR_LOGIN_LIBRUS_PORTAL_ACTION_ERROR
}.let { errorCode -> }.let { errorCode ->
@ -163,12 +175,6 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
return 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)) 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.data.web.*
import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.firstlogin.MobidziennikFirstLogin 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.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.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError 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 { private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {
return object : EdziennikCallback { return object : EdziennikCallback {
override fun onCompleted() { callback.onCompleted() } override fun onCompleted() { callback.onCompleted() }
override fun onRequiresUserAction(event: UserActionRequiredEvent) { callback.onRequiresUserAction(event) }
override fun onProgress(step: Float) { callback.onProgress(step) } override fun onProgress(step: Float) { callback.onProgress(step) }
override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) } override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) }
override fun onError(apiError: ApiError) { 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.firstlogin.PodlasieFirstLogin
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.login.PodlasieLogin 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.AttachmentGetEvent
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError 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() callback.onCompleted()
} }
override fun onRequiresUserAction(event: UserActionRequiredEvent) {
callback.onRequiresUserAction(event)
}
override fun onProgress(step: Float) { override fun onProgress(step: Float) {
callback.onProgress(step) 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.data.TemplateData
import pl.szczodrzynski.edziennik.data.api.edziennik.template.firstlogin.TemplateFirstLogin 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.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.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError 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() callback.onCompleted()
} }
override fun onRequiresUserAction(event: UserActionRequiredEvent) {
callback.onRequiresUserAction(event)
}
override fun onProgress(step: Float) { override fun onProgress(step: Float) {
callback.onProgress(step) 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.AttachmentGetEvent
import pl.szczodrzynski.edziennik.data.api.events.EventGetEvent import pl.szczodrzynski.edziennik.data.api.events.EventGetEvent
import pl.szczodrzynski.edziennik.data.api.events.MessageGetEvent 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.EdziennikCallback
import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface import pl.szczodrzynski.edziennik.data.api.interfaces.EdziennikInterface
import pl.szczodrzynski.edziennik.data.api.models.ApiError 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 { private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {
return object : EdziennikCallback { return object : EdziennikCallback {
override fun onCompleted() { callback.onCompleted() } override fun onCompleted() { callback.onCompleted() }
override fun onRequiresUserAction(event: UserActionRequiredEvent) { callback.onRequiresUserAction(event) }
override fun onProgress(step: Float) { callback.onProgress(step) } override fun onProgress(step: Float) { callback.onProgress(step) }
override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) } override fun onStartProgress(stringRes: Int) { callback.onStartProgress(stringRes) }
override fun onError(apiError: ApiError) { override fun onError(apiError: ApiError) {

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package pl.szczodrzynski.edziennik.data.api.models package pl.szczodrzynski.edziennik.data.api.models
import android.os.Bundle
import android.util.LongSparseArray import android.util.LongSparseArray
import android.util.SparseArray import android.util.SparseArray
import androidx.core.util.set import androidx.core.util.set
@ -12,7 +13,8 @@ import pl.szczodrzynski.edziennik.BuildConfig
import pl.szczodrzynski.edziennik.R import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.ERROR_REQUEST_FAILURE import pl.szczodrzynski.edziennik.data.api.ERROR_REQUEST_FAILURE
import pl.szczodrzynski.edziennik.data.api.Regexes.MESSAGE_META 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.AppDb
import pl.szczodrzynski.edziennik.data.db.entity.* import pl.szczodrzynski.edziennik.data.db.entity.*
import pl.szczodrzynski.edziennik.ext.* 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 * 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. * 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) 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) { fun progress(step: Float) {
callback.onProgress(step) callback.onProgress(step)
} }
@ -438,14 +449,14 @@ abstract class Data(val app: App, val profile: Profile?, val loginStore: LoginSt
return team 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" } 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 { fun getTeacher(firstNameChar: Char, lastName: String, loginId: String? = null): Teacher {
val teacher = teacherList.singleOrNull { it.shortName == "$firstNameChar.$lastName" } 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 { 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 teacher = teacherList.singleOrNull { it.fullNameLastFirst == nameLastFirst }
val nameParts = nameLastFirst.split(" ", limit = 2) val nameParts = nameLastFirst.split(" ", limit = 2)
return if (nameParts.size == 1) return if (nameParts.size == 1)
validateTeacher(teacher, nameParts[0], "", loginId) validateTeacher(teacher, nameParts[0], "", loginId, null)
else else
validateTeacher(teacher, nameParts[1], nameParts[0], loginId) validateTeacher(teacher, nameParts[1], nameParts[0], loginId, null)
} }
fun getTeacherByFirstLast(nameFirstLast: String, loginId: String? = null): Teacher { 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 teacher = teacherList.singleOrNull { it.fullName == nameFirstLast }
val nameParts = nameFirstLast.split(" ", limit = 2) val nameParts = nameFirstLast.split(" ", limit = 2)
return if (nameParts.size == 1) return if (nameParts.size == 1)
validateTeacher(teacher, nameParts[0], "", loginId) validateTeacher(teacher, nameParts[0], "", loginId, null)
else else
validateTeacher(teacher, nameParts[0], nameParts[1], loginId) validateTeacher(teacher, nameParts[0], nameParts[1], loginId, null)
} }
fun getTeacherByFDotLast(nameFDotLast: String, loginId: String? = null): Teacher { 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) getTeacher(nameParts[0][0], nameParts[1], loginId)
} }
private fun validateTeacher(teacher: Teacher?, firstName: String, lastName: String, loginId: String?): Teacher { private fun validateTeacher(
val obj = teacher ?: Teacher(profileId, -1, firstName, lastName, loginId).apply { teacher: Teacher?,
id = fullName.crc32() firstName: String,
teacherList[id] = this 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 { return obj.also {
if (loginId != null) if (loginId != null)

View File

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

View File

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

View File

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

View File

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

View File

@ -233,6 +233,10 @@ open class Profile(
MainActivity.DRAWER_ITEM_GRADES, MainActivity.DRAWER_ITEM_GRADES,
MainActivity.DRAWER_ITEM_HOMEWORK MainActivity.DRAWER_ITEM_HOMEWORK
) )
LOGIN_TYPE_USOS -> listOf(
MainActivity.DRAWER_ITEM_TIMETABLE,
MainActivity.DRAWER_ITEM_AGENDA
)
else -> listOf( else -> listOf(
MainActivity.DRAWER_ITEM_TIMETABLE, MainActivity.DRAWER_ITEM_TIMETABLE,
MainActivity.DRAWER_ITEM_AGENDA, 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 { fun Bundle?.getString(key: String, defaultValue: String): String {
return this?.getString(key, defaultValue) ?: defaultValue 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? { fun Bundle?.getIntOrNull(key: String): Int? {
return this?.get(key) as? 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 Bundle -> putBundle(property.first, property.second as Bundle)
is Parcelable -> putParcelable(property.first, property.second as Parcelable) is Parcelable -> putParcelable(property.first, property.second as Parcelable)
is Array<*> -> putParcelableArray(property.first, property.second as Array<out 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.JsonObject
import com.google.gson.JsonParser import com.google.gson.JsonParser
import com.google.gson.JsonPrimitive import com.google.gson.JsonPrimitive
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
fun JsonObject?.get(key: String): JsonElement? = this?.get(key) 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: JsonElement) = this.add(o)
operator fun JsonArray.plusAssign(o: String) = this.add(o) operator fun JsonArray.plusAssign(o: String) = this.add(o)
operator fun JsonArray.plusAssign(o: Char) = 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.PluralsRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.mikepenz.materialdrawer.holder.StringHolder import com.mikepenz.materialdrawer.holder.StringHolder
import java.net.URLDecoder
import java.net.URLEncoder
fun CharSequence?.isNotNullNorEmpty(): Boolean { fun CharSequence?.isNotNullNorEmpty(): Boolean {
return this != null && this.isNotEmpty() return this != null && this.isNotEmpty()
@ -343,3 +345,17 @@ fun Int.toStringHolder() = StringHolder(this)
fun CharSequence.toStringHolder() = StringHolder(this) fun CharSequence.toStringHolder() = StringHolder(this)
fun @receiver:StringRes Int.resolveString(context: Context) = context.getString(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 android.content.Context
import im.wangchao.mhttp.Response import im.wangchao.mhttp.Response
import pl.szczodrzynski.edziennik.R import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.Time import pl.szczodrzynski.edziennik.utils.models.Time
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.Locale
const val MINUTE = 60L const val MINUTE = 60L
const val HOUR = 60L*MINUTE const val HOUR = 60L*MINUTE
@ -115,3 +116,11 @@ fun Context.getSyncInterval(interval: Int): String {
"" ""
return hoursText?.plus(" $minutesText") ?: minutesText 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.ext.onClick
import pl.szczodrzynski.edziennik.ui.dialogs.base.BindingDialog import pl.szczodrzynski.edziennik.ui.dialogs.base.BindingDialog
class LibrusCaptchaDialog( class RecaptchaPromptDialog(
activity: AppCompatActivity, activity: AppCompatActivity,
private val siteKey: String,
private val referer: String,
private val onSuccess: (recaptchaCode: String) -> Unit, private val onSuccess: (recaptchaCode: String) -> Unit,
private val onFailure: (() -> Unit)?, private val onCancel: (() -> Unit)?,
onShowListener: ((tag: String) -> Unit)? = null, onShowListener: ((tag: String) -> Unit)? = null,
onDismissListener: ((tag: String) -> Unit)? = null, onDismissListener: ((tag: String) -> Unit)? = null,
) : BindingDialog<RecaptchaViewBinding>(activity, onShowListener, onDismissListener) { ) : BindingDialog<RecaptchaViewBinding>(activity, onShowListener, onDismissListener) {
override val TAG = "LibrusCaptchaDialog" override val TAG = "RecaptchaPromptDialog"
override fun getTitleRes(): Int? = null override fun getTitleRes(): Int? = null
override fun inflate(layoutInflater: LayoutInflater) = override fun inflate(layoutInflater: LayoutInflater) =
@ -46,8 +48,8 @@ class LibrusCaptchaDialog(
b.progress.visibility = View.VISIBLE b.progress.visibility = View.VISIBLE
RecaptchaDialog( RecaptchaDialog(
activity, activity,
siteKey = "6Lf48moUAAAAAB9ClhdvHr46gRWR-CN31CXQPG2U", siteKey = siteKey,
referer = "https://portal.librus.pl/rodzina/login", referer = referer,
onSuccess = { recaptchaCode -> onSuccess = { recaptchaCode ->
b.checkbox.background = checkboxBackground b.checkbox.background = checkboxBackground
b.checkbox.foreground = checkboxForeground b.checkbox.foreground = checkboxForeground
@ -67,6 +69,6 @@ class LibrusCaptchaDialog(
override fun onDismiss() { override fun onDismiss() {
if (!success) if (!success)
onFailure?.invoke() onCancel?.invoke()
} }
} }

View File

@ -27,7 +27,7 @@ import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.databinding.CardHomeDebugBinding import pl.szczodrzynski.edziennik.databinding.CardHomeDebugBinding
import pl.szczodrzynski.edziennik.ext.dp import pl.szczodrzynski.edziennik.ext.dp
import pl.szczodrzynski.edziennik.ext.onClick 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.HomeCard
import pl.szczodrzynski.edziennik.ui.home.HomeCardAdapter import pl.szczodrzynski.edziennik.ui.home.HomeCardAdapter
import pl.szczodrzynski.edziennik.ui.home.HomeFragment import pl.szczodrzynski.edziennik.ui.home.HomeFragment
@ -85,11 +85,6 @@ class HomeDebugCard(
app.startActivity(Chucker.getLaunchIntent(activity, 1)); app.startActivity(Chucker.getLaunchIntent(activity, 1));
} }
b.librusCaptchaButton.onClick {
//app.startActivity(Intent(activity, LoginLibrusCaptchaActivity::class.java))
LibrusCaptchaDialog(activity, onSuccess = {}, onFailure = {}).show()
}
b.getLogs.onClick { b.getLogs.onClick {
val logs = HyperLog.getDeviceLogsInFile(activity, true) val logs = HyperLog.getDeviceLogsInFile(activity, true)
val intent = Intent(Intent.ACTION_SEND) 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 val app: App by lazy { applicationContext as App }
private lateinit var b: LoginActivityBinding private lateinit var b: LoginActivityBinding
lateinit var navOptions: NavOptions lateinit var navOptions: NavOptions
lateinit var navOptionsBuilder: NavOptions.Builder
val nav by lazy { Navigation.findNavController(this, R.id.nav_host_fragment) } val nav by lazy { Navigation.findNavController(this, R.id.nav_host_fragment) }
val errorSnackbar: ErrorSnackbar by lazy { ErrorSnackbar(this) } val errorSnackbar: ErrorSnackbar by lazy { ErrorSnackbar(this) }
val swipeRefreshLayout: SwipeRefreshLayoutNoTouch by lazy { b.swipeRefreshLayout } val swipeRefreshLayout: SwipeRefreshLayoutNoTouch by lazy { b.swipeRefreshLayout }
@ -87,12 +88,12 @@ class LoginActivity : AppCompatActivity(), CoroutineScope {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setTheme(R.style.AppTheme_Light) setTheme(R.style.AppTheme_Light)
navOptions = NavOptions.Builder() navOptionsBuilder = NavOptions.Builder()
.setEnterAnim(R.anim.slide_in_right) .setEnterAnim(R.anim.slide_in_right)
.setExitAnim(R.anim.slide_out_left) .setExitAnim(R.anim.slide_out_left)
.setPopEnterAnim(R.anim.slide_in_left) .setPopEnterAnim(R.anim.slide_in_left)
.setPopExitAnim(R.anim.slide_out_right) .setPopExitAnim(R.anim.slide_out_right)
.build() navOptions = navOptionsBuilder.build()
b = LoginActivityBinding.inflate(layoutInflater) b = LoginActivityBinding.inflate(layoutInflater)
setContentView(b.root) setContentView(b.root)

View File

@ -14,6 +14,8 @@ import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.navigation.NavOptions
import androidx.navigation.navOptions
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import com.mikepenz.iconics.IconicsDrawable 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.FormCheckbox
import pl.szczodrzynski.edziennik.ui.login.LoginInfo.FormField import pl.szczodrzynski.edziennik.ui.login.LoginInfo.FormField
import pl.szczodrzynski.navlib.colorAttr import pl.szczodrzynski.navlib.colorAttr
import java.util.*
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class LoginFormFragment : Fragment(), CoroutineScope { class LoginFormFragment : Fragment(), CoroutineScope {
@ -63,8 +64,10 @@ class LoginFormFragment : Fragment(), CoroutineScope {
get() = arguments?.getString("platformDescription") get() = arguments?.getString("platformDescription")
private val platformFormFields private val platformFormFields
get() = arguments?.getString("platformFormFields")?.split(";") get() = arguments?.getString("platformFormFields")?.split(";")
private val platformRealmData private val platformData
get() = arguments?.getString("platformRealmData")?.toJsonObject() get() = arguments?.getString("platformData")?.toJsonObject()
private val platformStoreKey
get() = arguments?.getString("platformStoreKey")
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -90,6 +93,11 @@ class LoginFormFragment : Fragment(), CoroutineScope {
val loginMode = arguments?.getInt("loginMode") ?: return val loginMode = arguments?.getInt("loginMode") ?: return
val mode = register.loginModes.firstOrNull { it.loginMode == 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.title.setText(R.string.login_form_title_format, app.getString(register.registerName))
b.subTitle.text = platformName ?: app.getString(mode.name) b.subTitle.text = platformName ?: app.getString(mode.name)
b.text.text = platformGuideText ?: app.getString(mode.guideText) b.text.text = platformGuideText ?: app.getString(mode.guideText)
@ -250,7 +258,10 @@ class LoginFormFragment : Fragment(), CoroutineScope {
payload.putBoolean("fakeLogin", true) 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 var hasErrors = false
credentials.forEach { (credential, b) -> credentials.forEach { (credential, b) ->
@ -295,6 +306,14 @@ class LoginFormFragment : Fragment(), CoroutineScope {
if (hasErrors) if (hasErrors)
return 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.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import com.google.gson.JsonObject
import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.IIcon
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import pl.szczodrzynski.edziennik.R import pl.szczodrzynski.edziennik.R
@ -64,7 +65,6 @@ object LoginInfo {
errorCodes = mapOf( errorCodes = mapOf(
ERROR_LOGIN_LIBRUS_PORTAL_NOT_ACTIVATED to R.string.login_error_account_not_activated, 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_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( /*Mode(
@ -313,7 +313,24 @@ object LoginInfo {
errorCodes = mapOf() 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 icon: String,
val screenshot: String?, val screenshot: String?,
val formFields: List<String>, val formFields: List<String>,
val realmData: RealmData val data: JsonObject,
val storeKey: String?,
) )
open class BaseCredential( open class BaseCredential(

View File

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

View File

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

@ -21,8 +21,11 @@ import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.* 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.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.api.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.db.entity.Lesson import pl.szczodrzynski.edziennik.data.db.entity.Lesson
import pl.szczodrzynski.edziennik.data.db.full.AttendanceFull import pl.szczodrzynski.edziennik.data.db.full.AttendanceFull
@ -40,8 +43,8 @@ import pl.szczodrzynski.edziennik.utils.managers.NoteManager
import pl.szczodrzynski.edziennik.utils.models.Date import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.Time import pl.szczodrzynski.edziennik.utils.models.Time
import pl.szczodrzynski.edziennik.utils.mutableLazy import pl.szczodrzynski.edziennik.utils.mutableLazy
import java.util.*
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
import kotlin.math.max
import kotlin.math.min import kotlin.math.min
class TimetableDayFragment : LazyFragment(), CoroutineScope { class TimetableDayFragment : LazyFragment(), CoroutineScope {
@ -82,7 +85,7 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
startHour = startHour, startHour = startHour,
endHour = endHour, endHour = endHour,
dividerHeight = 1.dp, dividerHeight = 1.dp,
halfHourHeight = 60.dp, halfHourHeight = if (app.profile.loginStoreType == LOGIN_TYPE_USOS) 45.dp else 30.dp,
hourDividerColor = R.attr.hourDividerColor.resolveAttr(context), hourDividerColor = R.attr.hourDividerColor.resolveAttr(context),
halfHourDividerColor = R.attr.halfHourDividerColor.resolveAttr(context), halfHourDividerColor = R.attr.halfHourDividerColor.resolveAttr(context),
hourLabelWidth = 40.dp, hourLabelWidth = 40.dp,
@ -184,11 +187,18 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
val lessonsActual = lessons.filter { it.type != Lesson.TYPE_NO_LESSONS } 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) { if (profileConfig.timetableTrimHourRange) {
dayViewDelegate.deinitialize() dayViewDelegate.deinitialize()
// end/start defaults are swapped on purpose // end/start defaults are swapped on purpose
startHour = lessonsActual.minOf { it.displayStartTime?.hour ?: DEFAULT_END_HOUR } startHour = minStartHour
endHour = lessonsActual.maxOf { it.displayEndTime?.hour?.plus(1) ?: DEFAULT_START_HOUR } endHour = maxEndHour
} else if (startHour > minStartHour || endHour < maxEndHour) {
dayViewDelegate.deinitialize()
startHour = min(startHour, minStartHour)
endHour = max(endHour, maxEndHour)
} }
b.scrollView.isVisible = true b.scrollView.isVisible = true
@ -318,7 +328,7 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
lesson.getNoteSubstituteText(showNotes = true) ?: lesson.displaySubjectName lesson.getNoteSubstituteText(showNotes = true) ?: lesson.displaySubjectName
val (subjectTextPrimary, subjectTextSecondary) = if (profileConfig.timetableColorSubjectName) { val (subjectTextPrimary, subjectTextSecondary) = if (profileConfig.timetableColorSubjectName) {
val subjectColor = Colors.stringToMaterialColorCRC(lessonText?.toString() ?: "") val subjectColor = lesson.color ?: Colors.stringToMaterialColorCRC(lessonText?.toString() ?: "")
if (lb.annotationVisible) { if (lb.annotationVisible) {
lb.subjectContainer.background = ColorDrawable(subjectColor) lb.subjectContainer.background = ColorDrawable(subjectColor)
} else { } else {
@ -377,9 +387,8 @@ class TimetableDayFragment : LazyFragment(), CoroutineScope {
// The day view needs the event time ranges in the start minute/end minute format, // The day view needs the event time ranges in the start minute/end minute format,
// so calculate those here // so calculate those here
val startMinute = 60 * (lesson.displayStartTime?.hour val startMinute = 60 * startTime.hour + startTime.minute
?: 0) + (lesson.displayStartTime?.minute ?: 0) val endMinute = 60 * endTime.hour + endTime.minute
val endMinute = startMinute + 45
eventTimeRanges.add(DayView.EventTimeRange(startMinute, endMinute)) eventTimeRanges.add(DayView.EventTimeRange(startMinute, endMinute))
} }

View File

@ -7,58 +7,53 @@ package pl.szczodrzynski.edziennik.utils.managers
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import pl.szczodrzynski.edziennik.App import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.MainActivity import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.R 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.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.api.events.UserActionRequiredEvent 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.ext.Intent import pl.szczodrzynski.edziennik.ui.captcha.RecaptchaPromptDialog
import pl.szczodrzynski.edziennik.ext.JsonObject import pl.szczodrzynski.edziennik.ui.login.oauth.OAuthLoginActivity
import pl.szczodrzynski.edziennik.ext.pendingIntentFlag import pl.szczodrzynski.edziennik.ui.login.oauth.OAuthLoginResult
import pl.szczodrzynski.edziennik.ui.captcha.LibrusCaptchaDialog import pl.szczodrzynski.edziennik.utils.Utils.d
class UserActionManager(val app: App) { class UserActionManager(val app: App) {
companion object { companion object {
private const val TAG = "UserActionManager" private const val TAG = "UserActionManager"
} }
fun requiresUserAction(apiError: ApiError): Boolean { fun sendToUser(event: UserActionRequiredEvent) {
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
}
if (EventBus.getDefault().hasSubscriberForEvent(UserActionRequiredEvent::class.java)) { if (EventBus.getDefault().hasSubscriberForEvent(UserActionRequiredEvent::class.java)) {
EventBus.getDefault().post(UserActionRequiredEvent(apiError.profileId ?: -1, type)) EventBus.getDefault().post(event)
return return
} }
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val text = app.getString(event.errorText, event.profileId)
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 intent = Intent( val intent = Intent(
app, app,
MainActivity::class.java, MainActivity::class.java,
"action" to "userActionRequired", "action" to "userActionRequired",
"profileId" to (apiError.profileId ?: -1), "profileId" to event.profileId,
"type" to type "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)) .setContentTitle(app.getString(R.string.notification_user_action_required_title))
.setContentText(text) .setContentText(text)
.setSmallIcon(R.drawable.ic_error_outline) .setSmallIcon(R.drawable.ic_error_outline)
@ -74,29 +69,98 @@ class UserActionManager(val app: App) {
manager.notify(System.currentTimeMillis().toInt(), notification) manager.notify(System.currentTimeMillis().toInt(), notification)
} }
fun execute( class UserActionCallback(
activity: AppCompatActivity, val onSuccess: ((data: Bundle) -> Unit)? = null,
profileId: Int?, val onFailure: (() -> Unit)? = null,
type: Int, val onCancel: (() -> Unit)? = null,
onSuccess: ((code: String) -> Unit)? = null, )
onFailure: (() -> Unit)? = null
) {
if (type != UserActionRequiredEvent.CAPTCHA_LIBRUS)
return
if (profileId == null) fun execute(
return activity: AppCompatActivity,
// show captcha dialog event: UserActionRequiredEvent,
// use passed onSuccess listener, else sync profile callback: UserActionCallback,
LibrusCaptchaDialog( ) {
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, activity = activity,
onSuccess = onSuccess ?: { code -> siteKey = siteKey,
EdziennikTask.syncProfile(profileId, arguments = JsonObject( referer = referer,
onSuccess = { code ->
finishAction(activity, event, callback, Bundle(
"recaptchaCode" to code, "recaptchaCode" to code,
"recaptchaTime" to System.currentTimeMillis() "recaptchaTime" to System.currentTimeMillis(),
)).enqueue(activity) ))
}, },
onFailure = onFailure onCancel = callback.onCancel,
).show() ).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:layout_height="wrap_content"
android:text="Save Debug Logs" /> 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 <com.google.android.material.button.MaterialButton
android:id="@+id/refreshWidget" android:id="@+id/refreshWidget"
style="@style/Widget.MaterialComponents.Button" style="@style/Widget.MaterialComponents.Button"

View File

@ -30,11 +30,7 @@
<string name="error_111" translatable="false">ERROR_LOGIN_METHOD_NOT_SATISFIED</string> <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_112" translatable="false">ERROR_NOT_IMPLEMENTED</string>
<string name="error_113" translatable="false">ERROR_FILE_DOWNLOAD</string> <string name="error_113" translatable="false">ERROR_FILE_DOWNLOAD</string>
<string name="error_114" translatable="false">ERROR_REQUIRES_USER_ACTION</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_5000" translatable="false">ERROR_API_PDO_ERROR</string> <string name="error_5000" translatable="false">ERROR_API_PDO_ERROR</string>
<string name="error_5001" translatable="false">ERROR_API_INVALID_CLIENT</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_631" translatable="false">ERROR_PODLASIE_API_OTHER</string>
<string name="error_632" translatable="false">ERROR_PODLASIE_API_DATA_MISSING</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_801" translatable="false">ERROR_TEMPLATE_WEB_OTHER</string>
<string name="error_900" translatable="false">EXCEPTION_API_TASK</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_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_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_113_reason">Wystąpił błąd podczas pobierania pliku. Dziennik może być przeciążony lub mieć przerwę techniczną.</string>
<string name="error_114_reason">Wymagana akcja w aplikacji</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_5000_reason">ERROR_API_PDO_ERROR</string> <string name="error_5000_reason">ERROR_API_PDO_ERROR</string>
<string name="error_5001_reason">Nieprawidłowy ID klienta API</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_631_reason">ERROR_PODLASIE_API_OTHER</string>
<string name="error_632_reason">Brak danych. Zgłoś błąd programiście.</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_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> <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

@ -1542,4 +1542,10 @@
<string name="timetable_config_show_attendance">Pokazuj rodzaj obecności na lekcji</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_color_subject_name">Koloruj nazwę przedmiotu</string>
<string name="timetable_config_trim_hour_range">Nie pokazuj godzin bez lekcji</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> </resources>