Compare commits

...

64 Commits

Author SHA1 Message Date
3f36a284ee [4.5-beta.1] Update build.gradle, signing and changelog. 2021-02-21 16:40:40 +01:00
1814fd67e1 [Strings] Update copyright date. 2021-02-21 16:39:02 +01:00
54e49af943 [Vulcan/Hebe] Add getting timetable and lesson changes. 2021-02-21 16:18:33 +01:00
d6a67a0da6 [Vulcan/Hebe] Add getting homework list. 2021-02-21 12:18:53 +01:00
28725c6400 [Vulcan/Hebe] Add getting exams. 2021-02-21 00:27:42 +01:00
4fc965d970 [Vulcan/Hebe] Add saving unit ID. 2021-02-20 23:52:40 +01:00
2aaf713d58 [Vulcan/Hebe] Add next sync and data removing in grades. 2021-02-20 23:45:27 +01:00
c7d2ac4e3e [Vulcan/Hebe] Add getting grades. 2021-02-20 23:07:23 +01:00
ae20c30c88 [Vulcan/Hebe] Fix Firebase token for registration. 2021-02-20 21:31:52 +01:00
aef3f66654 [Vulcan/Hebe] Add API list helper. Separate student list code. 2021-02-20 20:11:54 +01:00
c7abde8f11 [Vulcan/Hebe] Update login mode requirement. 2021-02-20 18:51:08 +01:00
2fcff33bd6 [Vulcan/Hebe] Add hebe API login implementation. 2021-02-19 13:37:31 +01:00
73ff09052c [Gradle] Bump target SDK to 30. 2021-02-17 14:43:00 +01:00
9649afd43f [Gradle] Update libraries and dependencies. 2021-02-17 14:42:08 +01:00
ed3a245b51 [UI/Login] Add new easter eggs. 2020-10-18 22:14:41 +02:00
477730708f [UI/Login] Add refresh button in platform list. 2020-10-17 23:44:17 +02:00
f39d0c595d [UI/Login] Add recommended, testing and dev only badges to login modes. 2020-10-17 23:10:07 +02:00
46407f9647 [4.4.3] Update build.gradle, signing and changelog. 2020-10-16 23:54:48 +02:00
6ecb97b87e [Login/Podlasie] Add option to logout other devices. 2020-10-16 23:50:44 +02:00
ecdaaeae65 [API/Vulcan] Add KO1 routing rule. 2020-10-16 18:06:34 +02:00
a0c302b663 [App] Swap devMode with debugMode. Fix hiding sticks from old. 2020-10-16 17:18:19 +02:00
b31039ecd9 [API/Mobidziennik] Fix getting recipient list. 2020-10-16 16:57:00 +02:00
5c84086f42 [Settings/Grades] Add hiding sticks from old. 2020-10-14 22:26:47 +02:00
752cdfa8d6 Implement wear module base. 2020-09-17 18:05:17 +02:00
8e3d404352 [4.4.2] Update build.gradle, signing and changelog. 2020-09-05 19:14:11 +02:00
810cfd8092 [API] Rename response parameters to fix compatibility. 2020-09-05 19:13:37 +02:00
bd2a9524c6 [UI] Use HtmlCompat instead of Html. Fix a typo. 2020-09-05 18:47:30 +02:00
d780d5118d [Proguard] Add rules to fix API responses. 2020-09-05 18:38:53 +02:00
f1570b8eb9 [4.4.1] Update build.gradle, signing and changelog. 2020-09-04 15:44:26 +02:00
de0f29a09e [API/Mobidziennik] Fix getting attendance when a day has no lessons. 2020-09-04 00:04:23 +02:00
c0d11c91e3 [API/Mobidziennik] Fix trimming subject name in timetable. 2020-09-03 23:51:56 +02:00
22c540a3d4 [UI] Improve register unavailable dialog and card. 2020-09-03 23:50:53 +02:00
b7e35d0322 [4.4] Update build.gradle, signing and changelog. 2020-09-03 14:08:54 +02:00
7bcd6bf038 [Sync] Implement checking register availability. Improve app updates. 2020-09-03 13:39:46 +02:00
ea4591144b [4.3.1] Update build.gradle, signing and changelog. 2020-08-29 00:00:38 +02:00
7627d184a2 [API/Librus] Update client parameters. 2020-08-28 23:47:28 +02:00
076b485fda [API] Enable back sync before school year. 2020-08-28 23:27:34 +02:00
09cb97e367 [4.3] Update build.gradle, signing and changelog. 2020-08-28 15:26:42 +02:00
4e1f2ed41a [UI] Update date in about card subtext. Make gradlew executable. 2020-08-27 12:07:07 +02:00
281b6a95ef [API] Fix for syncing new profiles after archiving. 2020-08-27 00:10:19 +02:00
e40871c0d0 [UI] Update register names, again. 2020-08-26 22:15:48 +02:00
b74eeed994 [UI] Update register names. 2020-08-26 21:36:30 +02:00
ccde482364 [UI] Update register names. 2020-08-25 23:48:51 +02:00
a02033d0f3 [API] Fix archiving compatibility for older app versions. 2020-08-25 22:25:21 +02:00
6c6bc89f57 [UI] Improve archive-related UI. Add archived info home card. 2020-08-25 19:14:11 +02:00
1e3da45340 [UI] Remove unused home cards. 2020-08-25 17:02:12 +02:00
0d366adddb [UI] Implement showing archived profiles in drawer. 2020-08-25 16:01:11 +02:00
2c24eba46d [UI] Show bottom bar badge in debug versions. 2020-08-25 12:05:58 +02:00
7c6dbca986 [API] Implement basic profile archiving. 2020-08-25 10:46:50 +02:00
33a8fa2a1e [API] Fix doubled sync on API error. 2020-08-24 18:56:26 +02:00
300e2c4bc2 [API/Librus] Fix doubled sync on reCaptcha timeout. 2020-08-24 18:16:47 +02:00
f883318bd2 [Gradle] Fix compilation issues in latest Android Studio. 2020-08-24 17:42:53 +02:00
5460c1e2a0 [4.2.1] Update build.gradle, signing and changelog. 2020-05-21 23:12:25 +02:00
137c975e81 [API/Vulcan] Add getting Firebase token from server. 2020-05-21 22:07:31 +02:00
001de4a88c [Firebase] Fix getting FCM tokens and try to fix Vulcan registering. 2020-05-20 22:04:39 +02:00
5dcb3fd580 [Data] Fix setting correct time zone in ISO date parsing. 2020-05-18 12:22:21 +02:00
f13995aa5c [API/Mobidziennik] Fix lucky number extraction. 2020-05-18 11:42:58 +02:00
e23deb5ca6 [API/Podlasie] Fix security token generation. 2020-05-18 11:41:27 +02:00
d688b379a2 [4.2] Update build.gradle, signing and changelog. 2020-05-16 21:27:56 +02:00
d6a796e25e [Login] Crop Podlasie logo. Add Feedback activity, remove help icon. 2020-05-16 20:59:28 +02:00
e02d3e571d [API/Librus] Fix some more captcha errors. 2020-05-16 20:52:16 +02:00
907b75b22d [Proguard] Add rule for app login platform. 2020-05-16 20:46:54 +02:00
c3660b5f80 [Login] Change Librus logo. Disable Synergia & Vulcan e-mail login. 2020-05-16 20:44:56 +02:00
7ff10df70c [Login] Fix not showing sync fragment. 2020-05-16 20:33:37 +02:00
162 changed files with 4299 additions and 1233 deletions

View File

@ -1,6 +1,10 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
@ -11,7 +15,6 @@
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
@ -22,7 +25,6 @@
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
@ -34,7 +36,6 @@
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
@ -45,7 +46,6 @@
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
@ -56,7 +56,6 @@
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
@ -67,7 +66,6 @@
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
@ -78,7 +76,6 @@
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
@ -90,7 +87,6 @@
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
@ -102,7 +98,6 @@
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
@ -112,5 +107,8 @@
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

17
.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="1.7">
<module name="annotation" target="1.7" />
<module name="codegen" target="1.7" />
<module name="Szkolny.eu.agendacalendarview" target="11" />
<module name="Szkolny.eu.app" target="11" />
<module name="Szkolny.eu.cafebar" target="11" />
<module name="Szkolny.eu.material-about-library" target="11" />
<module name="Szkolny.eu.mhttp" target="11" />
<module name="Szkolny.eu.nachos" target="11" />
<module name="Szkolny.eu.szkolny-font" target="11" />
<module name="Szkolny.eu.wear" target="11" />
</bytecodeTargetLevel>
</component>
</project>

2
.idea/discord.xml generated
View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="true" />
<option name="show" value="PROJECT_FILES" />
</component>
<component name="ProjectNotificationSettings">
<option name="askShowProject" value="false" />

3
.idea/misc.xml generated
View File

@ -11,7 +11,6 @@
<item index="1" class="java.lang.String" itemvalue="org.greenrobot.eventbus.Subscribe" />
</list>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="NullableNotNullManager">
<option name="myDefaultNullable" value="org.jetbrains.annotations.Nullable" />
<option name="myDefaultNotNull" value="androidx.annotation.RecentlyNonNull" />
@ -51,7 +50,7 @@
</value>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" default="false" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@ -3,6 +3,7 @@
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.android.tools.idea.compose.preview.runconfiguration.ComposePreviewRunConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />

View File

@ -3,7 +3,7 @@ apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'com.google.gms.google-services'
apply plugin: 'io.fabric'
apply plugin: 'com.google.firebase.crashlytics'
android {
signingConfigs {
@ -54,10 +54,11 @@ android {
lintOptions {
checkReleaseBuilds false
}
dataBinding {
enabled = true
buildFeatures {
dataBinding = true
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility '1.8'
targetCompatibility '1.8'
}
@ -75,6 +76,7 @@ android {
version "3.10.2"
}
}
ndkVersion '21.3.6528147'
}
/*task finalizeBundleDebug(type: Copy) {
@ -104,6 +106,8 @@ tasks.whenTaskAdded { task ->
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1'
kapt "androidx.room:room-compiler:${versions.room}"
debugImplementation "com.amitshekhar.android:debug-db:1.0.5"
@ -114,7 +118,7 @@ dependencies {
implementation "androidx.core:core-ktx:${versions.ktx}"
implementation "androidx.gridlayout:gridlayout:${versions.gridLayout}"
implementation "androidx.legacy:legacy-support-v4:${versions.legacy}"
implementation "androidx.lifecycle:lifecycle-livedata:${versions.lifecycle}"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:${versions.lifecycle}"
implementation "androidx.recyclerview:recyclerview:${versions.recyclerView}"
implementation "androidx.room:room-runtime:${versions.room}"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${versions.kotlin}"
@ -137,7 +141,7 @@ dependencies {
implementation "cat.ereza:customactivityoncrash:2.2.0"
implementation "com.applandeo:material-calendar-view:1.5.0"
implementation "com.crashlytics.sdk.android:crashlytics:2.10.1"
implementation 'com.google.firebase:firebase-crashlytics:17.3.1'
implementation "com.daimajia.swipelayout:library:1.2.0@aar"
implementation "com.evernote:android-job:1.2.6"
implementation "com.github.antonKozyriatskyi:CircularProgressIndicator:1.2.2"
@ -157,7 +161,7 @@ dependencies {
implementation "me.grantland:autofittextview:0.2.1"
implementation "me.leolin:ShortcutBadger:1.1.22@aar"
implementation "org.greenrobot:eventbus:3.1.1"
implementation "org.jsoup:jsoup:1.10.1"
implementation "org.jsoup:jsoup:1.12.1"
implementation "pl.droidsonroids.gif:android-gif-drawable:1.2.15"
//implementation "se.emilsjolander:stickylistheaders:2.7.0"
implementation 'com.github.edisonw:StickyListHeaders:master-SNAPSHOT@aar'
@ -180,6 +184,7 @@ dependencies {
//implementation "org.redundent:kotlin-xml-builder:1.5.3"
implementation "io.github.wulkanowy:signer-android:0.1.1"
implementation 'com.github.wulkanowy.uonet-request-signer:hebe-jvm:a99ca50a31'
implementation "androidx.work:work-runtime-ktx:${versions.work}"
@ -203,8 +208,8 @@ dependencies {
implementation 'com.google.android:flexbox:2.0.1'
implementation 'com.qifan.powerpermission:powerpermission:1.0.0'
implementation 'com.qifan.powerpermission:powerpermission-coroutines:1.0.0'
implementation 'com.qifan.powerpermission:powerpermission:1.3.0'
implementation 'com.qifan.powerpermission:powerpermission-coroutines:1.3.0'
implementation 'com.github.kuba2k2.FSLogin:lib:master-SNAPSHOT'
implementation 'pl.droidsonroids:jspoon:1.3.2'

View File

@ -64,5 +64,6 @@
-keep class pl.szczodrzynski.edziennik.data.api.szkolny.interceptor.Signing { public final byte[] pleaseStopRightNow(java.lang.String, long); }
-keepclassmembernames class pl.szczodrzynski.edziennik.data.api.szkolny.request.** { *; }
-keepclassmembernames class pl.szczodrzynski.edziennik.data.api.szkolny.response.** { *; }
-keepclassmembers class pl.szczodrzynski.edziennik.data.api.szkolny.request.** { *; }
-keepclassmembers class pl.szczodrzynski.edziennik.data.api.szkolny.response.** { *; }
-keepclassmembernames class pl.szczodrzynski.edziennik.ui.modules.login.LoginInfo.Platform { *; }

View File

@ -1,10 +1,8 @@
<h3>Wersja 4.1, 2020-05-09</h3>
<h3>Wersja 4.5-beta.1, 2021-02-21</h3>
<ul>
<li>Naprawiona synchronizacja Wiadomości w Librusie.</li>
<li>Widok adresu dołączenia do lekcji online w kalendarzu (jeżeli nauczyciel wpisze adres).</li>
<li>Nowy moduł Frekwencji, obsługujący również lekcje zdalne.</li>
<li>Vulcan: aplikacja Szkolny.eu zaktualizowana w związku z wygaszeniem aplikacji Dzienniczek+.</li>
</ul>
<br>
<br>
Dzięki za korzystanie ze Szkolnego!<br>
<i>&copy; Kuba Szczodrzyński, Kacper Ziubryniewicz 2020</i>
<i>&copy; Kuba Szczodrzyński, Kacper Ziubryniewicz 2021</i>

View File

@ -9,7 +9,7 @@
/*secret password - removed for source code publication*/
static toys AES_IV[16] = {
0xec, 0xc0, 0x3e, 0x4e, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
0x35, 0x4c, 0x9d, 0x0e, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
unsigned char *agony(unsigned int laugh, unsigned char *box, unsigned char *heat);

View File

@ -43,6 +43,7 @@ import pl.szczodrzynski.edziennik.sync.SyncWorker
import pl.szczodrzynski.edziennik.sync.UpdateWorker
import pl.szczodrzynski.edziennik.ui.modules.base.CrashActivity
import pl.szczodrzynski.edziennik.utils.*
import pl.szczodrzynski.edziennik.utils.Utils.d
import pl.szczodrzynski.edziennik.utils.managers.*
import java.util.concurrent.TimeUnit
import kotlin.coroutines.CoroutineContext
@ -56,8 +57,8 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
val profileId
get() = profile.id
var devMode = false
var debugMode = false
var devMode = false
}
val notificationChannelsManager by lazy { NotificationChannelsManager(this) }
@ -106,7 +107,7 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
.readTimeout(30, TimeUnit.SECONDS)
builder.installHttpsSupport(this)
if (debugMode || BuildConfig.DEBUG) {
if (devMode || BuildConfig.DEBUG) {
HyperLog.initialize(this)
HyperLog.setLogLevel(Log.VERBOSE)
HyperLog.setLogFormat(DebugLogFormat(this))
@ -161,7 +162,7 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
Iconics.registerFont(SzkolnyFont)
App.db = AppDb(this)
Themes.themeInt = config.ui.theme
debugMode = config.debugMode
devMode = config.debugMode
MHttp.instance().customOkHttpClient(http)
if (!profileLoadById(config.lastProfileId)) {
@ -172,9 +173,9 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
setLanguage(it)
}
devMode = BuildConfig.DEBUG
debugMode = BuildConfig.DEBUG
if (BuildConfig.DEBUG)
debugMode = true
devMode = true
Signing.getCert(this)
@ -184,7 +185,7 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
if (config.devModePassword != null)
checkDevModePassword()
debugMode = devMode || config.debugMode
devMode = debugMode || config.debugMode
if (config.sync.enabled)
SyncWorker.scheduleNext(this@App, false)
@ -257,6 +258,10 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
val pushMobidziennikApp = FirebaseApp.initializeApp(
this@App,
FirebaseOptions.Builder()
.setProjectId("mobidziennik")
.setStorageBucket("mobidziennik.appspot.com")
.setDatabaseUrl("https://mobidziennik.firebaseio.com")
.setGcmSenderId("747285019373")
.setApiKey("AIzaSyCi5LmsZ5BBCQnGtrdvWnp1bWLCNP8OWQE")
.setApplicationId("1:747285019373:android:f6341bf7b158621d")
.build(),
@ -266,6 +271,10 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
val pushLibrusApp = FirebaseApp.initializeApp(
this@App,
FirebaseOptions.Builder()
.setProjectId("synergiadru")
.setStorageBucket("synergiadru.appspot.com")
.setDatabaseUrl("https://synergiadru.firebaseio.com")
.setGcmSenderId("513056078587")
.setApiKey("AIzaSyDfTuEoYPKdv4aceEws1CO3n0-HvTndz-o")
.setApplicationId("1:513056078587:android:1e29083b760af544")
.build(),
@ -275,19 +284,38 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
val pushVulcanApp = FirebaseApp.initializeApp(
this@App,
FirebaseOptions.Builder()
.setProjectId("dzienniczekplus")
.setStorageBucket("dzienniczekplus.appspot.com")
.setDatabaseUrl("https://dzienniczekplus.firebaseio.com")
.setGcmSenderId("987828170337")
.setApiKey("AIzaSyDW8MUtanHy64_I0oCpY6cOxB3jrvJd_iA")
.setApplicationId("1:987828170337:android:ac97431a0a4578c3")
.build(),
"Vulcan"
)
val pushVulcanHebeApp = FirebaseApp.initializeApp(
this@App,
FirebaseOptions.Builder()
.setProjectId("dzienniczekplus")
.setStorageBucket("dzienniczekplus.appspot.com")
.setDatabaseUrl("https://dzienniczekplus.firebaseio.com")
.setGcmSenderId("987828170337")
.setApiKey("AIzaSyDW8MUtanHy64_I0oCpY6cOxB3jrvJd_iA")
.setApplicationId("1:987828170337:android:7e16404b9e5deaaa")
.build(),
"VulcanHebe"
)
try {
FirebaseInstanceId.getInstance().instanceId.addOnSuccessListener { instanceIdResult ->
val token = instanceIdResult.token
d("Firebase", "Got App token: $token")
config.sync.tokenApp = token
}
FirebaseInstanceId.getInstance(pushMobidziennikApp).instanceId.addOnSuccessListener { instanceIdResult ->
val token = instanceIdResult.token
d("Firebase", "Got Mobidziennik2 token: $token")
if (token != config.sync.tokenMobidziennik) {
config.sync.tokenMobidziennik = token
config.sync.tokenMobidziennikList = listOf()
@ -295,6 +323,7 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
}
FirebaseInstanceId.getInstance(pushLibrusApp).instanceId.addOnSuccessListener { instanceIdResult ->
val token = instanceIdResult.token
d("Firebase", "Got Librus token: $token")
if (token != config.sync.tokenLibrus) {
config.sync.tokenLibrus = token
config.sync.tokenLibrusList = listOf()
@ -302,11 +331,20 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
}
FirebaseInstanceId.getInstance(pushVulcanApp).instanceId.addOnSuccessListener { instanceIdResult ->
val token = instanceIdResult.token
d("Firebase", "Got Vulcan token: $token")
if (token != config.sync.tokenVulcan) {
config.sync.tokenVulcan = token
config.sync.tokenVulcanList = listOf()
}
}
FirebaseInstanceId.getInstance(pushVulcanHebeApp).instanceId.addOnSuccessListener { instanceIdResult ->
val token = instanceIdResult.token
d("Firebase", "Got VulcanHebe token: $token")
if (token != config.sync.tokenVulcanHebe) {
config.sync.tokenVulcanHebe = token
config.sync.tokenVulcanHebeList = listOf()
}
}
FirebaseMessaging.getInstance().subscribeToTopic(packageName)
} catch (e: IllegalStateException) {
e.printStackTrace()
@ -347,6 +385,9 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope {
if (!success) {
EventBus.getDefault().post(ProfileListEmptyEvent())
}
else {
onSuccess(profile)
}
}
}
fun profileSave() = profileSave(profile)

View File

@ -42,6 +42,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import androidx.viewpager.widget.ViewPager
import com.google.android.gms.security.ProviderInstaller
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
@ -298,7 +299,7 @@ fun colorFromCssName(name: String): Int {
"orange" -> 0xffffa500
"black" -> 0xff000000
"white" -> 0xffffffff
else -> -1
else -> -1L
}.toInt()
}
@ -672,6 +673,16 @@ fun TextView.setText(@StringRes resid: Int, vararg formatArgs: Any) {
text = context.getString(resid, *formatArgs)
}
fun MaterialAlertDialogBuilder.setTitle(@StringRes resid: Int, vararg formatArgs: Any): MaterialAlertDialogBuilder {
setTitle(context.getString(resid, *formatArgs))
return this
}
fun MaterialAlertDialogBuilder.setMessage(@StringRes resid: Int, vararg formatArgs: Any): MaterialAlertDialogBuilder {
setMessage(context.getString(resid, *formatArgs))
return this
}
fun JsonObject(vararg properties: Pair<String, Any?>): JsonObject {
return JsonObject().apply {
for (property in properties) {
@ -1161,7 +1172,7 @@ fun Iterable<Float>.averageOrNull() = this.average().let { if (it.isNaN()) null
fun String.copyToClipboard(context: Context) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText("Tekst", this)
clipboard.primaryClip = clipData
clipboard.setPrimaryClip(clipData)
}
fun TextView.getTextPosition(range: IntRange): Rect {

View File

@ -41,13 +41,18 @@ import pl.droidsonroids.gif.GifDrawable
import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.api.events.*
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.api.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.data.api.szkolny.response.Update
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
import pl.szczodrzynski.edziennik.data.db.entity.Metadata.*
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.databinding.ActivitySzkolnyBinding
import pl.szczodrzynski.edziennik.sync.AppManagerDetectedEvent
import pl.szczodrzynski.edziennik.sync.SyncWorker
import pl.szczodrzynski.edziennik.sync.UpdateWorker
import pl.szczodrzynski.edziennik.ui.dialogs.RegisterUnavailableDialog
import pl.szczodrzynski.edziennik.ui.dialogs.ServerMessageDialog
import pl.szczodrzynski.edziennik.ui.dialogs.UpdateAvailableDialog
import pl.szczodrzynski.edziennik.ui.dialogs.changelog.ChangelogDialog
import pl.szczodrzynski.edziennik.ui.dialogs.event.EventManualDialog
import pl.szczodrzynski.edziennik.ui.dialogs.settings.ProfileRemoveDialog
@ -227,7 +232,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
list += NavTarget(TARGET_MESSAGES_DETAILS, R.string.menu_message, MessageFragment::class).withPopTo(DRAWER_ITEM_MESSAGES)
list += NavTarget(TARGET_MESSAGES_COMPOSE, R.string.menu_message_compose, MessagesComposeFragment::class)
list += NavTarget(TARGET_WEB_PUSH, R.string.menu_web_push, WebPushFragment::class)
if (App.debugMode) {
if (App.devMode) {
list += NavTarget(DRAWER_ITEM_DEBUG, R.string.menu_debug, DebugFragment::class)
list += NavTarget(TARGET_LAB, R.string.menu_lab, LabFragment::class)
.withIcon(CommunityMaterial.Icon.cmd_flask_outline)
@ -295,12 +300,21 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
mainSnackbar.setCoordinator(b.navView.coordinator, b.navView.bottomBar)
errorSnackbar.setCoordinator(b.navView.coordinator, b.navView.bottomBar)
if (BuildConfig.VERSION_NAME.contains("nightly")) {
b.nightlyText.isVisible = true
b.nightlyText.text = "Nightly\n"+BuildConfig.VERSION_NAME.substringAfterLast(".")
when {
BuildConfig.VERSION_NAME.contains("nightly") -> {
b.nightlyText.isVisible = true
b.nightlyText.text = "Nightly\n"+BuildConfig.VERSION_NAME.substringAfterLast(".")
}
BuildConfig.VERSION_NAME.contains("daily") -> {
b.nightlyText.isVisible = true
b.nightlyText.text = "Daily\n"+BuildConfig.VERSION_NAME.substringAfterLast(".")
}
BuildConfig.DEBUG -> {
b.nightlyText.isVisible = true
b.nightlyText.text = "Debug\n"+BuildConfig.VERSION_NAME
}
else -> b.nightlyText.isVisible = false
}
else
b.nightlyText.isVisible = false
navLoading = true
@ -399,7 +413,20 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
}
app.db.profileDao().all.observe(this, Observer { profiles ->
drawer.setProfileList(profiles.filter { it.id >= 0 }.toMutableList())
val allArchived = profiles.all { it.archived }
drawer.setProfileList(profiles.filter { it.id >= 0 && (!it.archived || allArchived) }.toMutableList())
//prepend the archived profile if loaded
if (app.profile.archived && !allArchived) {
drawer.prependProfile(Profile(
id = app.profile.id,
loginStoreId = app.profile.loginStoreId,
loginStoreType = app.profile.loginStoreType,
name = app.profile.name,
subname = "Archiwum - ${app.profile.subname}"
).also {
it.archived = true
})
}
drawer.currentProfile = App.profileId
})
@ -415,7 +442,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
})
b.swipeRefreshLayout.isEnabled = true
b.swipeRefreshLayout.setOnRefreshListener { this.syncCurrentFeature() }
b.swipeRefreshLayout.setOnRefreshListener { launch { syncCurrentFeature() } }
b.swipeRefreshLayout.setColorSchemeResources(
R.color.md_blue_500,
R.color.md_amber_500,
@ -425,6 +452,23 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
SyncWorker.scheduleNext(app)
UpdateWorker.scheduleNext(app)
// if loaded profile is archived, switch to the up-to-date version of it
if (app.profile.archived) {
launch {
if (app.profile.archiveId != null) {
val profile = withContext(Dispatchers.IO) {
app.db.profileDao().getNotArchivedOf(app.profile.archiveId!!)
}
if (profile != null)
loadProfile(profile)
else
loadProfile(0)
} else {
loadProfile(0)
}
}
}
// APP BACKGROUND
if (app.config.ui.appBackground != null) {
try {
@ -522,7 +566,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
.withIcon(CommunityMaterial.Icon2.cmd_help_circle_outline)
.withOnClickListener(View.OnClickListener { loadTarget(TARGET_FEEDBACK) })
)
if (App.debugMode) {
if (App.devMode) {
bottomSheet += BottomSheetPrimaryItem(false)
.withTitle(R.string.menu_debug)
.withIcon(CommunityMaterial.Icon.cmd_android_studio)
@ -564,7 +608,66 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
|_____/ \__, |_| |_|\___|
__/ |
|__*/
fun syncCurrentFeature() {
suspend fun syncCurrentFeature() {
if (app.profile.archived) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.profile_archived_title)
.setMessage(
R.string.profile_archived_text,
app.profile.studentSchoolYearStart,
app.profile.studentSchoolYearStart + 1
)
.setPositiveButton(R.string.ok, null)
.show()
swipeRefreshLayout.isRefreshing = false
return
}
if (app.profile.shouldArchive()) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.profile_archiving_title)
.setMessage(
R.string.profile_archiving_format,
app.profile.dateYearEnd.formattedString
)
.setPositiveButton(R.string.ok, null)
.show()
}
if (app.profile.isBeforeYear()) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.profile_year_not_started_title)
.setMessage(
R.string.profile_year_not_started_format,
app.profile.dateSemester1Start.formattedString
)
.setPositiveButton(R.string.ok, null)
.show()
swipeRefreshLayout.isRefreshing = false
return
}
app.profile.registerName?.let { registerName ->
var status = app.config.sync.registerAvailability[registerName]
if (status == null || status.nextCheckAt < currentTimeUnix()) {
withContext(Dispatchers.IO) {
val api = SzkolnyApi(app)
api.runCatching(this@MainActivity) {
val availability = getRegisterAvailability()
app.config.sync.registerAvailability = availability
status = availability[registerName]
}
}
}
if (status?.available != true
|| status?.minVersionCode ?: BuildConfig.VERSION_CODE > BuildConfig.VERSION_CODE) {
swipeRefreshLayout.isRefreshing = false
loadTarget(DRAWER_ITEM_HOME)
if (status != null)
RegisterUnavailableDialog(this, status!!)
return
}
}
swipeRefreshLayout.isRefreshing = true
Toast.makeText(this, fragmentToSyncName(navTargetId), Toast.LENGTH_SHORT).show()
val fragmentParam = when (navTargetId) {
@ -581,6 +684,20 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
arguments = arguments
).enqueue(this)
}
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun onUpdateEvent(event: Update) {
EventBus.getDefault().removeStickyEvent(event)
UpdateAvailableDialog(this, event)
}
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun onRegisterAvailabilityEvent(event: RegisterAvailabilityEvent) {
EventBus.getDefault().removeStickyEvent(event)
app.profile.registerName?.let { registerName ->
event.data[registerName]?.let {
RegisterUnavailableDialog(this, it)
}
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onApiTaskStartedEvent(event: ApiTaskStartedEvent) {
swipeRefreshLayout.isRefreshing = true
@ -885,23 +1002,51 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
fun loadProfile(id: Int) = loadProfile(id, navTargetId)
fun loadProfile(id: Int, arguments: Bundle?) = loadProfile(id, navTargetId, arguments)
fun loadProfile(id: Int, drawerSelection: Int, arguments: Bundle? = null) {
fun loadProfile(profile: Profile) = loadProfile(
profile,
navTargetId,
null,
if (app.profile.archived) app.profile.id else null
)
private fun loadProfile(id: Int, drawerSelection: Int, arguments: Bundle? = null) {
if (App.profileId == id) {
drawer.currentProfile = app.profile.id
loadTarget(drawerSelection, arguments)
return
}
val previousArchivedId = if (app.profile.archived) app.profile.id else null
app.profileLoad(id) {
MessagesFragment.pageSelection = -1
setDrawerItems()
// the drawer profile is updated automatically when the drawer item is clicked
// update it manually when switching profiles from other source
//if (drawer.currentProfile != app.profile.id)
drawer.currentProfile = app.profileId
loadTarget(drawerSelection, arguments)
loadProfile(it, drawerSelection, arguments, previousArchivedId)
}
}
private fun loadProfile(profile: Profile, drawerSelection: Int, arguments: Bundle?, previousArchivedId: Int?) {
App.profile = profile
MessagesFragment.pageSelection = -1
setDrawerItems()
if (previousArchivedId != null) {
// prevents accidentally removing the first item if the archived profile is not shown
drawer.removeProfileById(previousArchivedId)
}
if (profile.archived) {
drawer.prependProfile(Profile(
id = profile.id,
loginStoreId = profile.loginStoreId,
loginStoreType = profile.loginStoreType,
name = profile.name,
subname = "Archiwum - ${profile.subname}"
).also {
it.archived = true
})
}
// the drawer profile is updated automatically when the drawer item is clicked
// update it manually when switching profiles from other source
//if (drawer.currentProfile != app.profile.id)
drawer.currentProfile = app.profileId
loadTarget(drawerSelection, arguments)
}
fun loadTarget(id: Int, arguments: Bundle? = null) {
var loadId = id
if (loadId == -1) {

View File

@ -4,12 +4,18 @@
package pl.szczodrzynski.edziennik.config
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import pl.szczodrzynski.edziennik.config.utils.get
import pl.szczodrzynski.edziennik.config.utils.getIntList
import pl.szczodrzynski.edziennik.config.utils.set
import pl.szczodrzynski.edziennik.config.utils.setMap
import pl.szczodrzynski.edziennik.data.api.szkolny.response.RegisterAvailabilityStatus
import pl.szczodrzynski.edziennik.utils.models.Time
class ConfigSync(private val config: Config) {
private val gson = Gson()
private var mDontShowAppManagerDialog: Boolean? = null
var dontShowAppManagerDialog: Boolean
get() { mDontShowAppManagerDialog = mDontShowAppManagerDialog ?: config.values.get("dontShowAppManagerDialog", false); return mDontShowAppManagerDialog ?: false }
@ -93,6 +99,10 @@ class ConfigSync(private val config: Config) {
var tokenVulcan: String?
get() { mTokenVulcan = mTokenVulcan ?: config.values.get("tokenVulcan", null as String?); return mTokenVulcan }
set(value) { config.set("tokenVulcan", value); mTokenVulcan = value }
private var mTokenVulcanHebe: String? = null
var tokenVulcanHebe: String?
get() { mTokenVulcanHebe = mTokenVulcanHebe ?: config.values.get("tokenVulcanHebe", null as String?); return mTokenVulcanHebe }
set(value) { config.set("tokenVulcanHebe", value); mTokenVulcanHebe = value }
private var mTokenMobidziennikList: List<Int>? = null
var tokenMobidziennikList: List<Int>
@ -106,4 +116,13 @@ class ConfigSync(private val config: Config) {
var tokenVulcanList: List<Int>
get() { mTokenVulcanList = mTokenVulcanList ?: config.values.getIntList("tokenVulcanList", listOf()); return mTokenVulcanList ?: listOf() }
set(value) { config.set("tokenVulcanList", value); mTokenVulcanList = value }
private var mTokenVulcanHebeList: List<Int>? = null
var tokenVulcanHebeList: List<Int>
get() { mTokenVulcanHebeList = mTokenVulcanHebeList ?: config.values.getIntList("tokenVulcanHebeList", listOf()); return mTokenVulcanHebeList ?: listOf() }
set(value) { config.set("tokenVulcanHebeList", value); mTokenVulcanHebeList = value }
private var mRegisterAvailability: Map<String, RegisterAvailabilityStatus>? = null
var registerAvailability: Map<String, RegisterAvailabilityStatus>
get() { mRegisterAvailability = mRegisterAvailability ?: config.values.get("registerAvailability", null as String?)?.let { it -> gson.fromJson<Map<String, RegisterAvailabilityStatus>>(it, object: TypeToken<Map<String, RegisterAvailabilityStatus>>(){}.type) }; return mRegisterAvailability ?: mapOf() }
set(value) { config.setMap("registerAvailability", value); mRegisterAvailability = value }
}

View File

@ -49,4 +49,9 @@ class ProfileConfigGrades(private val config: ProfileConfig) {
var dontCountGrades: List<String>
get() { mDontCountGrades = mDontCountGrades ?: config.values.get("dontCountGrades", listOf()); return mDontCountGrades ?: listOf() }
set(value) { config.set("dontCountGrades", value); mDontCountGrades = value }
private var mHideSticksFromOld: Boolean? = null
var hideSticksFromOld: Boolean
get() { mHideSticksFromOld = mHideSticksFromOld ?: config.values.get("hideSticksFromOld", false); return mHideSticksFromOld ?: false }
set(value) { config.set("hideSticksFromOld", value); mHideSticksFromOld = value }
}

View File

@ -49,6 +49,9 @@ fun AbstractConfig.setIntList(key: String, value: List<Int>?) {
fun AbstractConfig.setLongList(key: String, value: List<Long>?) {
set(key, value?.let { gson.toJson(it) })
}
fun <K, V> AbstractConfig.setMap(key: String, value: Map<K, V>?) {
set(key, value?.let { gson.toJson(it) })
}
fun HashMap<String, String?>.get(key: String, default: String?): String? {
return this[key] ?: default

View File

@ -24,14 +24,14 @@ const val FAKE_LIBRUS_ACCOUNTS = "/synergia_accounts.php"
val LIBRUS_USER_AGENT = "${SYSTEM_USER_AGENT}LibrusMobileApp"
const val SYNERGIA_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/62.0"
const val LIBRUS_CLIENT_ID = "6XPsKf10LPz1nxgHQLcvZ1KM48DYzlBAhxipaXY8"
const val LIBRUS_REDIRECT_URL = "http://localhost/bar"
const val LIBRUS_CLIENT_ID = "0RbsDOkV9tyKEQYzlLv5hs3DM1ukrynFI4p6C1Yc"
const val LIBRUS_REDIRECT_URL = "app://librus"
const val LIBRUS_AUTHORIZE_URL = "https://portal.librus.pl/oauth2/authorize?client_id=$LIBRUS_CLIENT_ID&redirect_uri=$LIBRUS_REDIRECT_URL&response_type=code"
const val LIBRUS_LOGIN_URL = "https://portal.librus.pl/rodzina/login/action"
const val LIBRUS_TOKEN_URL = "https://portal.librus.pl/oauth2/access_token"
const val LIBRUS_ACCOUNT_URL = "/v2/SynergiaAccounts/fresh/" // + login
const val LIBRUS_ACCOUNTS_URL = "/v2/SynergiaAccounts"
const val LIBRUS_ACCOUNT_URL = "/v3/SynergiaAccounts/fresh/" // + login
const val LIBRUS_ACCOUNTS_URL = "/v3/SynergiaAccounts"
/** https://api.librus.pl/2.0 */
const val LIBRUS_API_URL = "https://api.librus.pl/2.0"
@ -92,10 +92,19 @@ val MOBIDZIENNIK_USER_AGENT = SYSTEM_USER_AGENT
const val VULCAN_API_USER_AGENT = "MobileUserAgent"
const val VULCAN_API_APP_NAME = "VULCAN-Android-ModulUcznia"
const val VULCAN_API_APP_VERSION = "19.4.1.436"
const val VULCAN_API_APP_VERSION = "20.5.1.470"
const val VULCAN_API_PASSWORD = "CE75EA598C7743AD9B0B7328DED85B06"
const val VULCAN_API_PASSWORD_FAKELOG = "012345678901234567890123456789AB"
val VULCAN_API_DEVICE_NAME = "Szkolny.eu ${Build.MODEL}"
const val VULCAN_HEBE_USER_AGENT = "Dart/2.10 (dart:io)"
const val VULCAN_HEBE_APP_NAME = "DzienniczekPlus 2.0"
const val VULCAN_HEBE_APP_VERSION = "21.02.09 (G)"
private const val VULCAN_API_DEVICE_NAME_PREFIX = "Szkolny.eu "
private const val VULCAN_API_DEVICE_NAME_SUFFIX = " - nie usuwać"
val VULCAN_API_DEVICE_NAME by lazy {
val base = "$VULCAN_API_DEVICE_NAME_PREFIX${Build.MODEL}"
val baseMaxLength = 50 - VULCAN_API_DEVICE_NAME_SUFFIX.length
base.take(baseMaxLength) + VULCAN_API_DEVICE_NAME_SUFFIX
}
const val VULCAN_API_ENDPOINT_CERTIFICATE = "mobile-api/Uczen.v3.UczenStart/Certyfikat"
const val VULCAN_API_ENDPOINT_STUDENT_LIST = "mobile-api/Uczen.v3.UczenStart/ListaUczniow"
@ -116,9 +125,17 @@ const val VULCAN_API_ENDPOINT_MESSAGES_ATTACHMENTS = "mobile-api/Uczen.v3.Uczen/
const val VULCAN_API_ENDPOINT_HOMEWORK_ATTACHMENTS = "mobile-api/Uczen.v3.Uczen/ZadaniaDomoweZalacznik"
const val VULCAN_WEB_ENDPOINT_LUCKY_NUMBER = "Start.mvc/GetKidsLuckyNumbers"
const val VULCAN_WEB_ENDPOINT_REGISTER_DEVICE = "RejestracjaUrzadzeniaToken.mvc/Get"
const val VULCAN_HEBE_ENDPOINT_REGISTER_NEW = "api/mobile/register/new"
const val VULCAN_HEBE_ENDPOINT_MAIN = "api/mobile/register/hebe"
const val VULCAN_HEBE_ENDPOINT_TIMETABLE = "api/mobile/schedule"
const val VULCAN_HEBE_ENDPOINT_TIMETABLE_CHANGES = "api/mobile/schedule/changes"
const val VULCAN_HEBE_ENDPOINT_EXAMS = "api/mobile/exam"
const val VULCAN_HEBE_ENDPOINT_GRADES = "api/mobile/grade"
const val VULCAN_HEBE_ENDPOINT_HOMEWORK = "api/mobile/homework"
const val EDUDZIENNIK_USER_AGENT = "Szkolny.eu/${BuildConfig.VERSION_NAME}"
const val PODLASIE_API_VERSION = "1.0.31"
const val PODLASIE_API_VERSION = "1.0.62"
const val PODLASIE_API_URL = "https://cpdklaser.zeto.bialystok.pl/api"
const val PODLASIE_API_USER_ENDPOINT = "/pobierzDaneUcznia"
const val PODLASIE_API_LOGOUT_DEVICES_ENDPOINT = "/wyczyscUrzadzenia"

View File

@ -170,6 +170,7 @@ const val ERROR_VULCAN_WEB_LOGGED_OUT = 350
const val ERROR_VULCAN_WEB_CERTIFICATE_POST_FAILED = 351
const val ERROR_VULCAN_WEB_GRADUATE_ACCOUNT = 352
const val ERROR_VULCAN_WEB_NO_SCHOOLS = 353
const val ERROR_VULCAN_HEBE_OTHER = 354
const val ERROR_LOGIN_IDZIENNIK_WEB_INVALID_LOGIN = 401
const val ERROR_LOGIN_IDZIENNIK_WEB_INVALID_SCHOOL_NAME = 402
@ -229,5 +230,6 @@ const val ERROR_ONEDRIVE_DOWNLOAD = 930
const val EXCEPTION_VULCAN_WEB_LOGIN = 931
const val EXCEPTION_VULCAN_WEB_REQUEST = 932
const val EXCEPTION_PODLASIE_API_REQUEST = 940
const val EXCEPTION_VULCAN_HEBE_REQUEST = 950
const val LOGIN_NO_ARGUMENTS = 1201

View File

@ -17,6 +17,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.login.PodlasieLogi
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.vulcan.login.VulcanLoginApi
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLoginHebe
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLoginWebMain
import pl.szczodrzynski.edziennik.data.api.models.LoginMethod
@ -98,11 +99,13 @@ val mobidziennikLoginMethods = listOf(
const val LOGIN_TYPE_VULCAN = 4
const val LOGIN_MODE_VULCAN_API = 0
const val LOGIN_MODE_VULCAN_WEB = 1
const val LOGIN_MODE_VULCAN_HEBE = 2
const val LOGIN_METHOD_VULCAN_WEB_MAIN = 100
const val LOGIN_METHOD_VULCAN_WEB_NEW = 200
const val LOGIN_METHOD_VULCAN_WEB_OLD = 300
const val LOGIN_METHOD_VULCAN_WEB_MESSAGES = 400
const val LOGIN_METHOD_VULCAN_API = 500
const val LOGIN_METHOD_VULCAN_HEBE = 600
val vulcanLoginMethods = listOf(
LoginMethod(LOGIN_TYPE_VULCAN, LOGIN_METHOD_VULCAN_WEB_MAIN, VulcanLoginWebMain::class.java)
.withIsPossible { _, loginStore -> loginStore.hasLoginData("webHost") }
@ -117,9 +120,19 @@ val vulcanLoginMethods = listOf(
.withRequiredLoginMethod { _, _ -> LOGIN_METHOD_VULCAN_WEB_MAIN },*/
LoginMethod(LOGIN_TYPE_VULCAN, LOGIN_METHOD_VULCAN_API, VulcanLoginApi::class.java)
.withIsPossible { _, _ -> true }
.withIsPossible { _, loginStore ->
loginStore.mode != LOGIN_MODE_VULCAN_HEBE
}
.withRequiredLoginMethod { _, loginStore ->
if (loginStore.mode == LOGIN_MODE_VULCAN_WEB) LOGIN_METHOD_VULCAN_WEB_MAIN else LOGIN_METHOD_NOT_NEEDED
},
LoginMethod(LOGIN_TYPE_VULCAN, LOGIN_METHOD_VULCAN_HEBE, VulcanLoginHebe::class.java)
.withIsPossible { _, loginStore ->
loginStore.mode != LOGIN_MODE_VULCAN_API
}
.withRequiredLoginMethod { _, loginStore ->
if (loginStore.mode == LOGIN_MODE_VULCAN_WEB) LOGIN_METHOD_VULCAN_WEB_MAIN else LOGIN_METHOD_NOT_NEEDED
}
)

View File

@ -12,6 +12,10 @@ object Regexes {
"""color: (\w+);?""".toRegex()
}
val NOT_DIGITS by lazy {
"""[^0-9]""".toRegex()
}
val MOBIDZIENNIK_GRADES_SUBJECT_NAME by lazy {
@ -40,7 +44,7 @@ object Regexes {
"""\(([0-9A-ząęóżźńśłć]*?)\)$""".toRegex(DOT_MATCHES_ALL)
}
val MOBIDZIENNIK_LUCKY_NUMBER by lazy {
"""class="szczesliwy_numerek".*>0*([0-9]+)(?:/0*[0-9]+)*</a>""".toRegex(DOT_MATCHES_ALL)
"""class="szczesliwy_numerek".*?>0?([0-9]+)/?0?([0-9]+)?</a>""".toRegex(DOT_MATCHES_ALL)
}
val MOBIDZIENNIK_CLASS_CALENDAR by lazy {
"""events: (.+),$""".toRegex(RegexOption.MULTILINE)
@ -80,6 +84,15 @@ object Regexes {
val MOBIDZIENNIK_ATTENDANCE_ENTRIES by lazy {
"""font-size:.+?class=".*?">(.*?)</td>""".toRegex(DOT_MATCHES_ALL)
}
val MOBIDZIENNIK_ATTENDANCE_COLUMNS by lazy {
"""<tr><td class="border-right1".+?/td>(.+?)</tr>""".toRegex(DOT_MATCHES_ALL)
}
val MOBIDZIENNIK_ATTENDANCE_COLUMN by lazy {
"""(<td.+?>)(.*?)</td>""".toRegex(DOT_MATCHES_ALL)
}
val MOBIDZIENNIK_ATTENDANCE_COLUMN_SPAN by lazy {
"""colspan="(\d+)"""".toRegex()
}
val MOBIDZIENNIK_ATTENDANCE_RANGE by lazy {
"""<span>([0-9:]+) - .+? (.+?)</span></a>""".toRegex(DOT_MATCHES_ALL)
}

View File

@ -5,8 +5,8 @@
package pl.szczodrzynski.edziennik.data.api.edziennik
import com.google.gson.JsonObject
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.edudziennik.Edudziennik
import pl.szczodrzynski.edziennik.data.api.edziennik.idziennik.Idziennik
@ -15,9 +15,11 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.Mobidziennik
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.Podlasie
import pl.szczodrzynski.edziennik.data.api.edziennik.template.Template
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.Vulcan
import pl.szczodrzynski.edziennik.data.api.events.RegisterAvailabilityEvent
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.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.data.api.task.IApiTask
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
import pl.szczodrzynski.edziennik.data.db.entity.Profile
@ -25,6 +27,7 @@ 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
open class EdziennikTask(override val profileId: Int, val request: Any) : IApiTask(profileId) {
companion object {
@ -71,10 +74,51 @@ open class EdziennikTask(override val profileId: Int, val request: Any) : IApiTa
private var edziennikInterface: EdziennikInterface? = null
internal fun run(app: App, taskCallback: EdziennikCallback) {
if (profile?.archived == true) {
taskCallback.onError(ApiError(TAG, ERROR_PROFILE_ARCHIVED))
return
profile?.let { profile ->
if (profile.archived) {
d(TAG, "The profile $profileId is archived")
taskCallback.onError(ApiError(TAG, ERROR_PROFILE_ARCHIVED))
return
}
else if (profile.shouldArchive()) {
d(TAG, "The profile $profileId's year ended on ${profile.dateYearEnd}, archiving")
ProfileArchiver(app, profile)
}
if (profile.isBeforeYear()) {
d(TAG, "The profile $profileId's school year has not started yet; aborting sync")
cancel()
taskCallback.onCompleted()
return
}
profile.registerName?.let { registerName ->
var status = app.config.sync.registerAvailability[registerName]
if (status == null || status.nextCheckAt < currentTimeUnix()) {
val api = SzkolnyApi(app)
api.runCatching({
val availability = getRegisterAvailability()
app.config.sync.registerAvailability = availability
status = availability[registerName]
}, onError = {
taskCallback.onError(it.toApiError(TAG))
return
})
}
if (status?.available != true
|| status?.minVersionCode ?: BuildConfig.VERSION_CODE > BuildConfig.VERSION_CODE) {
if (EventBus.getDefault().hasSubscriberForEvent(RegisterAvailabilityEvent::class.java)) {
EventBus.getDefault().postSticky(
RegisterAvailabilityEvent(app.config.sync.registerAvailability)
)
}
cancel()
taskCallback.onCompleted()
return
}
}
}
edziennikInterface = when (loginStore.type) {
LOGIN_TYPE_LIBRUS -> Librus(app, profile, loginStore, taskCallback)
LOGIN_TYPE_MOBIDZIENNIK -> Mobidziennik(app, profile, loginStore, taskCallback)
@ -108,6 +152,7 @@ open class EdziennikTask(override val profileId: Int, val request: Any) : IApiTa
}
override fun cancel() {
d(TAG, "Task ${toString()} cancelling...")
edziennikInterface?.cancel()
}

View File

@ -0,0 +1,97 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-8-25.
*/
package pl.szczodrzynski.edziennik.data.api.edziennik
import android.content.Intent
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.Intent
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.utils.Utils.d
import pl.szczodrzynski.edziennik.utils.models.Date
class ProfileArchiver(val app: App, val profile: Profile) {
companion object {
private const val TAG = "ProfileArchiver"
}
init {
if (profile.archiveId == null)
profile.archiveId = profile.id
d(TAG, "Processing ${profile.name}#${profile.id}, archiveId = ${profile.archiveId}")
profile.archived = true
app.db.profileDao().add(profile)
//app.db.metadataDao().setAllSeen(profile.id, true)
app.db.notificationDao().clear(profile.id)
app.db.endpointTimerDao().clear(profile.id)
d(TAG, "Archived profile ${profile.id} saved")
profile.archived = false
// guess the nearest school year
val today = Date.getToday()
profile.studentSchoolYearStart = when {
today.month <= profile.dateYearEnd.month -> today.year - 1
else -> today.year
}
// set default semester dates
profile.dateSemester1Start = Date(profile.studentSchoolYearStart, 9, 1)
profile.dateSemester2Start = Date(profile.studentSchoolYearStart + 1, 2, 1)
profile.dateYearEnd = Date(profile.studentSchoolYearStart + 1, 6, 30)
val oldId = profile.id
val newId = (app.db.profileDao().lastId ?: profile.id) + 1
profile.id = newId
profile.subname = "Nowy rok szkolny - ${profile.studentSchoolYearStart}"
profile.studentClassName = null
d(TAG, "New profile ID for ${profile.name}: ${profile.id}")
when (profile.loginStoreType) {
LOGIN_TYPE_LIBRUS -> {
profile.removeStudentData("isPremium")
profile.removeStudentData("pushDeviceId")
profile.removeStudentData("startPointsSemester1")
profile.removeStudentData("startPointsSemester2")
profile.removeStudentData("enablePointGrades")
profile.removeStudentData("enableDescriptiveGrades")
}
LOGIN_TYPE_MOBIDZIENNIK -> {
}
LOGIN_TYPE_VULCAN -> {
// DataVulcan.isApiLoginValid() returns false so it will update the semester
profile.removeStudentData("currentSemesterEndDate")
profile.removeStudentData("studentSemesterId")
profile.removeStudentData("studentSemesterNumber")
profile.removeStudentData("semester1Id")
profile.removeStudentData("semester2Id")
profile.removeStudentData("studentClassId")
}
LOGIN_TYPE_IDZIENNIK -> {
profile.removeStudentData("schoolYearId")
}
LOGIN_TYPE_EDUDZIENNIK -> {
}
LOGIN_TYPE_PODLASIE -> {
}
}
d(TAG, "Processed student data: ${profile.studentData}")
app.db.profileDao().add(profile)
if (app.profileId == oldId) {
val intent = Intent(
Intent.ACTION_MAIN,
"profileId" to newId
)
app.sendBroadcast(intent)
}
}
}

View File

@ -110,7 +110,6 @@ class Edudziennik(val app: App, val profile: Profile?, val loginStore: LoginStor
override fun cancel() {
d(TAG, "Cancelled")
data.cancel()
callback.onCompleted()
}
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {

View File

@ -133,7 +133,6 @@ class Idziennik(val app: App, val profile: Profile?, val loginStore: LoginStore,
override fun cancel() {
d(TAG, "Cancelled")
data.cancel()
callback.onCompleted()
}
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {

View File

@ -70,6 +70,14 @@ class IdziennikLoginWeb(val data: DataIdziennik, val onSuccess: () -> Unit) {
data.webSelectedRegister = registerId
}
// for profiles created after archiving
data.schoolYearId = Regexes.IDZIENNIK_LOGIN_FIRST_SCHOOL_YEAR.find(text)?.let {
it[1].toIntOrNull()
} ?: data.schoolYearId
data.profile?.studentClassName = Regexes.IDZIENNIK_LOGIN_FIRST_STUDENT.findAll(text)
.firstOrNull { it[1].toIntOrNull() == data.registerId }
?.let { "${it[5]} ${it[6]}" } ?: data.profile?.studentClassName
data.profile?.let { profile ->
Regexes.IDZIENNIK_WEB_LUCKY_NUMBER.find(text)?.also {
val number = it[1].toIntOrNull() ?: return@also

View File

@ -157,7 +157,6 @@ class Librus(val app: App, val profile: Profile?, val loginStore: LoginStore, va
override fun cancel() {
d(TAG, "Cancelled")
data.cancel()
callback.onCompleted()
}
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {

View File

@ -36,11 +36,14 @@ class LibrusRecaptchaHelper(
}
private var timeout: Job? = null
private var timedOut = false
inner class WebViewClient : android.webkit.WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
timeout?.cancel()
onSuccess(url)
if (!timedOut) {
onSuccess(url)
}
return true
}
}
@ -50,6 +53,7 @@ class LibrusRecaptchaHelper(
webView.loadDataWithBaseURL(url, html, "text/html", "UTF-8", null)
}
timeout = startCoroutineTimer(delayMillis = 10000L) {
timedOut = true
onTimeout()
}
}

View File

@ -38,14 +38,18 @@ class LibrusLoginMessages(val data: DataLibrus, val onSuccess: () -> Unit) {
text?.contains("grecaptcha.ready") == true -> {
val url = response?.request()?.url()?.toString() ?: run {
data.error(TAG, ERROR_LIBRUS_MESSAGES_OTHER, response, text)
//data.error(TAG, ERROR_LIBRUS_MESSAGES_OTHER, response, text)
data.messagesLoginSuccessful = false
onSuccess()
return
}
LibrusRecaptchaHelper(data.app, url, text, onSuccess = { newUrl ->
loginWithSynergia(newUrl)
}, onTimeout = {
data.error(TAG, ERROR_LOGIN_LIBRUS_MESSAGES_TIMEOUT, response, text)
//data.error(TAG, ERROR_LOGIN_LIBRUS_MESSAGES_TIMEOUT, response, text)
data.messagesLoginSuccessful = false
onSuccess()
})
}

View File

@ -66,7 +66,7 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
override fun onSuccess(text: String, response: Response) {
val location = response.headers().get("Location")
if (location != null) {
val authMatcher = Pattern.compile("http://localhost/bar\\?code=([A-z0-9]+?)$", Pattern.DOTALL or Pattern.MULTILINE).matcher(location)
val authMatcher = Pattern.compile("$LIBRUS_REDIRECT_URL\\?code=([A-z0-9]+?)$", Pattern.DOTALL or Pattern.MULTILINE).matcher(location)
when {
authMatcher.find() -> {
accessToken(authMatcher.group(1), null)
@ -127,7 +127,7 @@ class LibrusLoginPortal(val data: DataLibrus, val onSuccess: () -> Unit) {
.callback(object : JsonCallbackHandler() {
override fun onSuccess(json: JsonObject?, response: Response) {
val location = response.headers()?.get("Location")
if (location == "http://localhost/bar?command=close") {
if (location == "$LIBRUS_REDIRECT_URL?command=close") {
data.error(ApiError(TAG, ERROR_LIBRUS_PORTAL_MAINTENANCE)
.withApiResponse(json)
.withResponse(response))

View File

@ -130,7 +130,6 @@ class Mobidziennik(val app: App, val profile: Profile?, val loginStore: LoginSto
override fun cancel() {
d(TAG, "Cancelled")
data.cancel()
callback.onCompleted()
}
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {

View File

@ -44,7 +44,7 @@ class MobidziennikApiTimetable(val data: DataMobidziennik, rows: List<String>) {
dataDays.remove(date.value)
val subjectId = data.subjectList.singleOrNull { it.longName == lesson[5] }?.id ?: -1
val subjectId = data.subjectList.singleOrNull { it.longName == lesson[5].trim() }?.id ?: -1
val teacherId = data.teacherList.singleOrNull { it.fullNameLastFirst == (lesson[7]+" "+lesson[6]).fixName() }?.id ?: -1
val teamId = data.teamList.singleOrNull { it.name == lesson[8]+lesson[9] }?.id ?: -1
val classroom = lesson[11]

View File

@ -91,8 +91,11 @@ class MobidziennikWebAttendance(override val data: DataMobidziennik,
Regexes.MOBIDZIENNIK_ATTENDANCE_TABLE.findAll(text).forEach { tableResult ->
val table = tableResult[1]
val lessonDates = mutableListOf<Date>()
val entries = mutableListOf<String>()
val ranges = mutableListOf<MatchResult?>()
Regexes.MOBIDZIENNIK_ATTENDANCE_LESSON_COUNT.findAll(table).forEach {
val date = Date.fromY_m_d(it[1])
for (i in 0 until (it[2].toIntOrNull() ?: 0)) {
@ -101,102 +104,52 @@ class MobidziennikWebAttendance(override val data: DataMobidziennik,
}
Regexes.MOBIDZIENNIK_ATTENDANCE_ENTRIES.findAll(table).mapTo(entries) { it[1] }
Regexes.MOBIDZIENNIK_ATTENDANCE_COLUMNS.findAll(table).forEach { columns ->
var index = 0
Regexes.MOBIDZIENNIK_ATTENDANCE_COLUMN.findAll(columns[1]).forEach { column ->
if (column[1].contains("colspan")) {
val colspan =
Regexes.MOBIDZIENNIK_ATTENDANCE_COLUMN_SPAN.find(column[1])
?.get(1)
?.toIntOrNull() ?: 0
entries.addAll(index, List(colspan) { "" })
ranges.addAll(List(colspan) { null })
index += colspan
}
else {
val range = Regexes.MOBIDZIENNIK_ATTENDANCE_RANGE.find(column[2])
ranges.add(range)
index++
}
}
}
val dateIterator = lessonDates.iterator()
val entriesIterator = entries.iterator()
Regexes.MOBIDZIENNIK_ATTENDANCE_RANGE.findAll(table).let { ranges ->
val count = ranges.count()
// verify the lesson count is the same as dates & entries
if (count != lessonDates.count() || count != entries.count())
val count = ranges.count()
// verify the lesson count is the same as dates & entries
if (count != lessonDates.count() || count != entries.count())
return@forEach
ranges.forEach { range ->
val lessonDate = dateIterator.next()
val entry = entriesIterator.next()
if (range == null || entry.isBlank())
return@forEach
ranges.forEach { range ->
val lessonDate = dateIterator.next()
var entry = entriesIterator.next()
if (entry.isBlank())
return@forEach
val startTime = Time.fromH_m(range[1])
val startTime = Time.fromH_m(range[1])
range[2].split(" / ").mapNotNull { Regexes.MOBIDZIENNIK_ATTENDANCE_LESSON.find(it) }.forEachIndexed { index, lesson ->
val topic = lesson[1].substringAfter(" - ", missingDelimiterValue = "").takeIf { it.isNotBlank() }
if (topic?.startsWith("Lekcja odwołana: ") == true || entry.isEmpty())
return@forEachIndexed
val subjectName = lesson[1].substringBefore(" - ")
//val team = lesson[3]
val teacherName = lesson[3].fixName()
val teacherId = data.teacherList.singleOrNull { it.fullNameLastFirst == teacherName }?.id ?: -1
val subjectId = data.subjectList.singleOrNull { it.longName == subjectName }?.id ?: -1
var typeSymbol = ""
for (symbol in typeSymbols) {
if (entry.startsWith(symbol) && symbol.length > typeSymbol.length)
typeSymbol = symbol
}
entry = entry.removePrefix(typeSymbol)
var isCounted = true
val baseType = when (typeSymbol) {
"." -> TYPE_PRESENT
"|" -> TYPE_ABSENT
"+" -> TYPE_ABSENT_EXCUSED
"s" -> TYPE_BELATED
"z" -> TYPE_RELEASED
else -> {
isCounted = false
when (typeSymbol) {
"e" -> TYPE_PRESENT_CUSTOM
"en" -> TYPE_ABSENT
"ep" -> TYPE_PRESENT_CUSTOM
else -> TYPE_UNKNOWN
}
}
}
val typeName = types?.get(typeSymbol) ?: ""
val typeColor = when (typeSymbol) {
"e" -> 0xff673ab7
"en" -> 0xffec407a
"ep" -> 0xff4caf50
else -> null
}?.toInt()
val typeShort = if (isCounted)
data.app.attendanceManager.getTypeShort(baseType)
else
typeSymbol
val semester = data.profile?.dateToSemester(lessonDate) ?: 1
val id = lessonDate.combineWith(startTime) / 6L * 10L + (lesson[0].hashCode() and 0xFFFF) + index
val attendanceObject = Attendance(
profileId = profileId,
id = id,
baseType = baseType,
typeName = typeName,
typeShort = typeShort,
typeSymbol = typeSymbol,
typeColor = typeColor,
date = lessonDate,
startTime = startTime,
semester = semester,
teacherId = teacherId,
subjectId = subjectId
).also {
it.lessonTopic = topic
it.isCounted = isCounted
}
data.attendanceList.add(attendanceObject)
if (baseType != TYPE_PRESENT) {
data.metadataList.add(
Metadata(
data.profileId,
Metadata.TYPE_ATTENDANCE,
id,
data.profile?.empty ?: false || baseType == Attendance.TYPE_PRESENT_CUSTOM || baseType == TYPE_UNKNOWN,
data.profile?.empty ?: false || baseType == Attendance.TYPE_PRESENT_CUSTOM || baseType == TYPE_UNKNOWN
))
}
}
range[2].split(" / ").mapNotNull {
Regexes.MOBIDZIENNIK_ATTENDANCE_LESSON.find(it)
}.forEachIndexed { index, lesson ->
processEntry(
index,
lesson,
lessonDate,
startTime,
entry,
types,
typeSymbols
)
}
}
}
@ -206,4 +159,97 @@ class MobidziennikWebAttendance(override val data: DataMobidziennik,
onSuccess()
}
}
private fun processEntry(
index: Int,
lesson: MatchResult,
lessonDate: Date,
startTime: Time,
entry: String,
types: Map<String?, String?>?,
typeSymbols: List<String>
) {
var entry = entry
val topic = lesson[1].substringAfter(" - ", missingDelimiterValue = "").takeIf { it.isNotBlank() }
if (topic?.startsWith("Lekcja odwołana: ") == true || entry.isEmpty())
return
val subjectName = lesson[1].substringBefore(" - ")
//val team = lesson[3]
val teacherName = lesson[3].fixName()
val teacherId = data.teacherList.singleOrNull { it.fullNameLastFirst == teacherName }?.id ?: -1
val subjectId = data.subjectList.singleOrNull { it.longName == subjectName }?.id ?: -1
var typeSymbol = ""
for (symbol in typeSymbols) {
if (entry.startsWith(symbol) && symbol.length > typeSymbol.length)
typeSymbol = symbol
}
entry = entry.removePrefix(typeSymbol)
var isCounted = true
val baseType = when (typeSymbol) {
"." -> TYPE_PRESENT
"|" -> TYPE_ABSENT
"+" -> TYPE_ABSENT_EXCUSED
"s" -> TYPE_BELATED
"z" -> TYPE_RELEASED
else -> {
isCounted = false
when (typeSymbol) {
"e" -> TYPE_PRESENT_CUSTOM
"en" -> TYPE_ABSENT
"ep" -> TYPE_PRESENT_CUSTOM
else -> TYPE_UNKNOWN
}
}
}
val typeName = types?.get(typeSymbol) ?: ""
val typeColor = when (typeSymbol) {
"e" -> 0xff673ab7
"en" -> 0xffec407a
"ep" -> 0xff4caf50
else -> null
}?.toInt()
val typeShort = if (isCounted)
data.app.attendanceManager.getTypeShort(baseType)
else
typeSymbol
val semester = data.profile?.dateToSemester(lessonDate) ?: 1
val id = lessonDate.combineWith(startTime) / 6L * 10L + (lesson[0].hashCode() and 0xFFFF) + index
val attendanceObject = Attendance(
profileId = profileId,
id = id,
baseType = baseType,
typeName = typeName,
typeShort = typeShort,
typeSymbol = typeSymbol,
typeColor = typeColor,
date = lessonDate,
startTime = startTime,
semester = semester,
teacherId = teacherId,
subjectId = subjectId
).also {
it.lessonTopic = topic
it.isCounted = isCounted
}
data.attendanceList.add(attendanceObject)
if (baseType != TYPE_PRESENT) {
data.metadataList.add(
Metadata(
data.profileId,
Metadata.TYPE_ATTENDANCE,
id,
data.profile?.empty ?: false || baseType == Attendance.TYPE_PRESENT_CUSTOM || baseType == TYPE_UNKNOWN,
data.profile?.empty ?: false || baseType == Attendance.TYPE_PRESENT_CUSTOM || baseType == TYPE_UNKNOWN
))
}
}
}

View File

@ -56,20 +56,21 @@ class MobidziennikWebGetRecipientList(override val data: DataMobidziennik,
}
private fun processRecipient(listType: Int, listName: String, recipient: JsonObject) {
val id = recipient.getLong("id") ?: -1
val id = recipient.getString("id") ?: return
val idLong = id.replace(Regexes.NOT_DIGITS, "").toLongOrNull() ?: return
// get teacher by ID or create it
val teacher = data.teacherList[id] ?: Teacher(data.profileId, id).apply {
val teacher = data.teacherList[idLong] ?: Teacher(data.profileId, idLong).apply {
val fullName = recipient.getString("nazwa")?.fixName()
name = fullName ?: ""
fullName?.splitName()?.let {
name = it.second
surname = it.first
}
data.teacherList[id] = this
data.teacherList[idLong] = this
}
teacher.apply {
loginId = id.toString()
loginId = id
when (listType) {
1 -> setTeacherType(Teacher.TYPE_PRINCIPAL)
2 -> setTeacherType(Teacher.TYPE_TEACHER)

View File

@ -134,7 +134,6 @@ class Podlasie(val app: App, val profile: Profile?, val loginStore: LoginStore,
override fun cancel() {
Utils.d(TAG, "Cancelled")
data.cancel()
callback.onCompleted()
}
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {

View File

@ -102,7 +102,7 @@ open class PodlasieApi(open val data: DataPodlasie, open val lastSync: Long?) {
.also { it.timeZone = TimeZone.getTimeZone("Europe/Warsaw") }.format(System.currentTimeMillis())
val instance = MessageDigest.getInstance("SHA-256")
val digest = instance.digest("-EYlwYu8u16miVd8tT?oO7cvoUVQrQN0vr!$format".toByteArray()).toHexString()
val digest2 = instance.digest(data.apiToken!!.toByteArray()).toHexString()
val digest2 = instance.digest((data.apiToken ?: "").toByteArray()).toHexString()
return instance.digest("$digest$digest2".toByteArray()).toHexString()
}
}

View File

@ -7,6 +7,7 @@ package pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.firstlogin
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_PODLASIE
import pl.szczodrzynski.edziennik.data.api.PODLASIE_API_LOGOUT_DEVICES_ENDPOINT
import pl.szczodrzynski.edziennik.data.api.PODLASIE_API_USER_ENDPOINT
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.DataPodlasie
import pl.szczodrzynski.edziennik.data.api.edziennik.podlasie.data.PodlasieApi
@ -22,50 +23,62 @@ class PodlasieFirstLogin(val data: DataPodlasie, val onSuccess: () -> Unit) {
private val api = PodlasieApi(data, null)
init {
PodlasieLoginApi(data) {
doLogin()
}
}
private fun doLogin() {
val loginStoreId = data.loginStore.id
val loginStoreType = LOGIN_TYPE_PODLASIE
PodlasieLoginApi(data) {
api.apiGet(TAG, PODLASIE_API_USER_ENDPOINT) { json ->
val uuid = json.getString("Uuid")
val login = json.getString("Login")
val firstName = json.getString("FirstName")
val lastName = json.getString("LastName")
val studentNameLong = "$firstName $lastName".fixName()
val studentNameShort = studentNameLong.getShortName()
val schoolName = json.getString("SchoolName")
val className = json.getString("SchoolClass")
val schoolYear = json.getString("ActualSchoolYear")?.replace(' ', '/')
val semester = json.getString("ActualTermShortcut")?.length
val apiUrl = json.getString("URL")
val profile = Profile(
loginStoreId,
loginStoreId,
loginStoreType,
studentNameLong,
login,
studentNameLong,
studentNameShort,
null
).apply {
studentData["studentId"] = uuid
studentData["studentLogin"] = login
studentData["schoolName"] = schoolName
studentData["className"] = className
studentData["schoolYear"] = schoolYear
studentData["currentSemester"] = semester ?: 1
studentData["apiUrl"] = apiUrl
schoolYear?.split('/')?.get(0)?.toInt()?.let {
studentSchoolYearStart = it
}
studentClassName = className
}
EventBus.getDefault().postSticky(FirstLoginFinishedEvent(listOf(profile), data.loginStore))
onSuccess()
if (data.loginStore.getLoginData("logoutDevices", false)) {
data.loginStore.removeLoginData("logoutDevices")
api.apiGet(TAG, PODLASIE_API_LOGOUT_DEVICES_ENDPOINT) {
doLogin()
}
return
}
api.apiGet(TAG, PODLASIE_API_USER_ENDPOINT) { json ->
val uuid = json.getString("Uuid")
val login = json.getString("Login")
val firstName = json.getString("FirstName")
val lastName = json.getString("LastName")
val studentNameLong = "$firstName $lastName".fixName()
val studentNameShort = studentNameLong.getShortName()
val schoolName = json.getString("SchoolName")
val className = json.getString("SchoolClass")
val schoolYear = json.getString("ActualSchoolYear")?.replace(' ', '/')
val semester = json.getString("ActualTermShortcut")?.length
val apiUrl = json.getString("URL")
val profile = Profile(
loginStoreId,
loginStoreId,
loginStoreType,
studentNameLong,
login,
studentNameLong,
studentNameShort,
null
).apply {
studentData["studentId"] = uuid
studentData["studentLogin"] = login
studentData["schoolName"] = schoolName
studentData["className"] = className
studentData["schoolYear"] = schoolYear
studentData["currentSemester"] = semester ?: 1
studentData["apiUrl"] = apiUrl
schoolYear?.split('/')?.get(0)?.toInt()?.let {
studentSchoolYearStart = it
}
studentClassName = className
}
EventBus.getDefault().postSticky(FirstLoginFinishedEvent(listOf(profile), data.loginStore))
onSuccess()
}
}
}

View File

@ -100,7 +100,6 @@ class Template(val app: App, val profile: Profile?, val loginStore: LoginStore,
override fun cancel() {
d(TAG, "Cancelled")
data.cancel()
callback.onCompleted()
}
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {

View File

@ -4,16 +4,16 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.currentTimeUnix
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_VULCAN_API
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_VULCAN_HEBE
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_VULCAN_WEB_MAIN
import pl.szczodrzynski.edziennik.data.api.LOGIN_MODE_VULCAN_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
import pl.szczodrzynski.edziennik.data.db.entity.Team
import pl.szczodrzynski.edziennik.isNotNullNorEmpty
import pl.szczodrzynski.edziennik.utils.Utils
import pl.szczodrzynski.edziennik.values
class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app, profile, loginStore) {
@ -26,17 +26,27 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
&& apiFingerprint[symbol].isNotNullNorEmpty()
&& apiPrivateKey[symbol].isNotNullNorEmpty()
&& symbol.isNotNullNorEmpty()
fun isHebeLoginValid() = hebePublicKey.isNotNullNorEmpty()
&& hebePrivateKey.isNotNullNorEmpty()
&& symbol.isNotNullNorEmpty()
override fun satisfyLoginMethods() {
loginMethods.clear()
if (isWebMainLoginValid()) {
loginMethods += LOGIN_METHOD_VULCAN_WEB_MAIN
}
if (isApiLoginValid()) {
loginMethods += LOGIN_METHOD_VULCAN_API
}
if (isHebeLoginValid()) {
loginMethods += LOGIN_METHOD_VULCAN_HEBE
}
}
init {
// during the first sync `profile.studentClassName` is already set
if (teamList.values().none { it.type == Team.TYPE_CLASS }) {
if (loginStore.mode == LOGIN_MODE_VULCAN_API
&& teamList.values().none { it.type == Team.TYPE_CLASS }) {
profile?.studentClassName?.also { name ->
val id = Utils.crc16(name.toByteArray()).toLong()
@ -55,6 +65,17 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
override fun generateUserCode() = "$schoolCode:$studentId"
fun buildDeviceId(): String {
val deviceId = app.deviceId.padStart(16, '0')
val loginStoreId = loginStore.id.toString(16).padStart(4, '0')
val symbol = symbol?.crc16()?.toString(16)?.take(2) ?: "00"
return deviceId.substring(0..7) +
"-" + deviceId.substring(8..11) +
"-" + deviceId.substring(12..15) +
"-" + loginStoreId +
"-" + symbol + "6f72616e7a"
}
/**
* A UONET+ client symbol.
*
@ -139,6 +160,16 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
get() { mStudentSemesterId = mStudentSemesterId ?: profile?.getStudentData("studentSemesterId", 0); return mStudentSemesterId ?: 0 }
set(value) { profile?.putStudentData("studentSemesterId", value) ?: return; mStudentSemesterId = value }
private var mStudentUnitId: Int? = null
var studentUnitId: Int
get() { mStudentUnitId = mStudentUnitId ?: profile?.getStudentData("studentUnitId", 0); return mStudentUnitId ?: 0 }
set(value) { profile?.putStudentData("studentUnitId", value) ?: return; mStudentUnitId = value }
private var mStudentConstituentId: Int? = null
var studentConstituentId: Int
get() { mStudentConstituentId = mStudentConstituentId ?: profile?.getStudentData("studentConstituentId", 0); return mStudentConstituentId ?: 0 }
set(value) { profile?.putStudentData("studentConstituentId", value) ?: return; mStudentConstituentId = value }
private var mSemester1Id: Int? = null
var semester1Id: Int
get() { mSemester1Id = mSemester1Id ?: profile?.getStudentData("semester1Id", 0); return mSemester1Id ?: 0 }
@ -203,6 +234,32 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
get() { mApiPrivateKey = mApiPrivateKey ?: loginStore.getLoginData("apiPrivateKey", null)?.let { app.gson.fromJson(it, field.toMutableMap()::class.java) }; return mApiPrivateKey ?: mapOf() }
set(value) { loginStore.putLoginData("apiPrivateKey", app.gson.toJson(value)); mApiPrivateKey = value }
/* _ _ _ _____ _____
| | | | | | /\ | __ \_ _|
| |__| | ___| |__ ___ / \ | |__) || |
| __ |/ _ \ '_ \ / _ \ / /\ \ | ___/ | |
| | | | __/ |_) | __/ / ____ \| | _| |_
|_| |_|\___|_.__/ \___| /_/ \_\_| |____*/
private var mHebePublicKey: String? = null
var hebePublicKey: String?
get() { mHebePublicKey = mHebePublicKey ?: loginStore.getLoginData("hebePublicKey", null); return mHebePublicKey }
set(value) { loginStore.putLoginData("hebePublicKey", value); mHebePublicKey = value }
private var mHebePrivateKey: String? = null
var hebePrivateKey: String?
get() { mHebePrivateKey = mHebePrivateKey ?: loginStore.getLoginData("hebePrivateKey", null); return mHebePrivateKey }
set(value) { loginStore.putLoginData("hebePrivateKey", value); mHebePrivateKey = value }
private var mHebePublicHash: String? = null
var hebePublicHash: String?
get() { mHebePublicHash = mHebePublicHash ?: loginStore.getLoginData("hebePublicHash", null); return mHebePublicHash }
set(value) { loginStore.putLoginData("hebePublicHash", value); mHebePublicHash = value }
private var mHebeContext: String? = null
var hebeContext: String?
get() { mHebeContext = mHebeContext ?: profile?.getStudentData("hebeContext", null); return mHebeContext }
set(value) { profile?.putStudentData("hebeContext", value) ?: return; mHebeContext = value }
val apiUrl: String?
get() {
val url = when (apiToken[symbol]?.substring(0, 3)) {
@ -219,6 +276,7 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
"P01" -> "http://efeb-komunikacja.pro-hudson.win.vulcan.pl"
"P02" -> "http://efeb-komunikacja.pro-hudsonrc.win.vulcan.pl"
"P90" -> "http://efeb-komunikacja-pro-mwujakowska.neo.win.vulcan.pl"
"KO1" -> "https://uonetplus-komunikacja.eduportal.koszalin.pl"
"FK1", "FS1" -> "http://api.fakelog.cf"
"SZ9" -> "http://hack.szkolny.eu"
else -> null
@ -226,7 +284,7 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
return if (url != null) "$url/$symbol/" else loginStore.getLoginData("apiUrl", null)
}
val fullApiUrl: String?
val fullApiUrl: String
get() {
return "$apiUrl$schoolSymbol/"
}

View File

@ -194,7 +194,6 @@ class Vulcan(val app: App, val profile: Profile?, val loginStore: LoginStore, va
override fun cancel() {
d(TAG, "Cancelled")
data.cancel()
callback.onCompleted()
}
private fun wrapCallback(callback: EdziennikCallback): EdziennikCallback {

View File

@ -20,25 +20,42 @@ const val ENDPOINT_VULCAN_API_ATTENDANCE = 1080
const val ENDPOINT_VULCAN_API_MESSAGES_INBOX = 1090
const val ENDPOINT_VULCAN_API_MESSAGES_SENT = 1100
const val ENDPOINT_VULCAN_WEB_LUCKY_NUMBERS = 2010
const val ENDPOINT_VULCAN_HEBE_MAIN = 3000
const val ENDPOINT_VULCAN_HEBE_TIMETABLE = 3020
const val ENDPOINT_VULCAN_HEBE_EXAMS = 3030
const val ENDPOINT_VULCAN_HEBE_GRADES = 3040
const val ENDPOINT_VULCAN_HEBE_HOMEWORK = 3060
val VulcanFeatures = listOf(
// timetable
Feature(LOGIN_TYPE_VULCAN, FEATURE_TIMETABLE, listOf(
ENDPOINT_VULCAN_API_TIMETABLE to LOGIN_METHOD_VULCAN_API
), listOf(LOGIN_METHOD_VULCAN_API)),
Feature(LOGIN_TYPE_VULCAN, FEATURE_TIMETABLE, listOf(
ENDPOINT_VULCAN_HEBE_TIMETABLE to LOGIN_METHOD_VULCAN_HEBE
), listOf(LOGIN_METHOD_VULCAN_HEBE)),
// agenda
Feature(LOGIN_TYPE_VULCAN, FEATURE_AGENDA, listOf(
ENDPOINT_VULCAN_API_EVENTS to LOGIN_METHOD_VULCAN_API
), listOf(LOGIN_METHOD_VULCAN_API)),
Feature(LOGIN_TYPE_VULCAN, FEATURE_AGENDA, listOf(
ENDPOINT_VULCAN_HEBE_EXAMS to LOGIN_METHOD_VULCAN_HEBE
), listOf(LOGIN_METHOD_VULCAN_HEBE)),
// grades
Feature(LOGIN_TYPE_VULCAN, FEATURE_GRADES, listOf(
ENDPOINT_VULCAN_API_GRADES to LOGIN_METHOD_VULCAN_API,
ENDPOINT_VULCAN_API_GRADES_SUMMARY to LOGIN_METHOD_VULCAN_API
), listOf(LOGIN_METHOD_VULCAN_API)),
Feature(LOGIN_TYPE_VULCAN, FEATURE_GRADES, listOf(
ENDPOINT_VULCAN_HEBE_GRADES to LOGIN_METHOD_VULCAN_HEBE
), listOf(LOGIN_METHOD_VULCAN_HEBE)),
// homework
Feature(LOGIN_TYPE_VULCAN, FEATURE_HOMEWORK, listOf(
ENDPOINT_VULCAN_API_HOMEWORK to LOGIN_METHOD_VULCAN_API
), listOf(LOGIN_METHOD_VULCAN_API)),
Feature(LOGIN_TYPE_VULCAN, FEATURE_HOMEWORK, listOf(
ENDPOINT_VULCAN_HEBE_HOMEWORK to LOGIN_METHOD_VULCAN_HEBE
), listOf(LOGIN_METHOD_VULCAN_HEBE)),
// behaviour
Feature(LOGIN_TYPE_VULCAN, FEATURE_BEHAVIOUR, listOf(
ENDPOINT_VULCAN_API_NOTICES to LOGIN_METHOD_VULCAN_API

View File

@ -7,6 +7,10 @@ package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.*
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe.VulcanHebeExams
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe.VulcanHebeGrades
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe.VulcanHebeHomework
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe.VulcanHebeTimetable
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.web.VulcanWebLuckyNumber
import pl.szczodrzynski.edziennik.utils.Utils
@ -91,6 +95,22 @@ class VulcanData(val data: DataVulcan, val onSuccess: () -> Unit) {
data.startProgress(R.string.edziennik_progress_endpoint_lucky_number)
VulcanWebLuckyNumber(data, lastSync, onSuccess)
}
ENDPOINT_VULCAN_HEBE_TIMETABLE -> {
data.startProgress(R.string.edziennik_progress_endpoint_timetable)
VulcanHebeTimetable(data, lastSync, onSuccess)
}
ENDPOINT_VULCAN_HEBE_EXAMS -> {
data.startProgress(R.string.edziennik_progress_endpoint_exams)
VulcanHebeExams(data, lastSync, onSuccess)
}
ENDPOINT_VULCAN_HEBE_GRADES -> {
data.startProgress(R.string.edziennik_progress_endpoint_grades)
VulcanHebeGrades(data, lastSync, onSuccess)
}
ENDPOINT_VULCAN_HEBE_HOMEWORK -> {
data.startProgress(R.string.edziennik_progress_endpoint_homework)
VulcanHebeHomework(data, lastSync, onSuccess)
}
else -> onSuccess(endpointId)
}
}

View File

@ -0,0 +1,359 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data
import android.os.Build
import androidx.core.util.set
import com.google.gson.JsonArray
import com.google.gson.JsonElement
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.JsonCallbackHandler
import io.github.wulkanowy.signer.hebe.getSignatureHeaders
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe.HebeFilterType
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.db.entity.LessonRange
import pl.szczodrzynski.edziennik.data.db.entity.Subject
import pl.szczodrzynski.edziennik.data.db.entity.Teacher
import pl.szczodrzynski.edziennik.data.db.entity.Team
import pl.szczodrzynski.edziennik.utils.Utils.d
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.Time
import java.net.HttpURLConnection
import java.net.URLEncoder
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.*
open class VulcanHebe(open val data: DataVulcan, open val lastSync: Long?) {
companion object {
const val TAG = "VulcanHebe"
}
val profileId
get() = data.profile?.id ?: -1
val profile
get() = data.profile
fun getDateTime(json: JsonObject?, key: String): Long {
val date = json.getJsonObject(key)
return date.getLong("Timestamp") ?: return System.currentTimeMillis()
}
fun getDate(json: JsonObject?, key: String): Date? {
val date = json.getJsonObject(key)
return date.getString("Date")?.let { Date.fromY_m_d(it) }
}
fun getTeacherId(json: JsonObject?, key: String): Long? {
val teacher = json.getJsonObject(key)
val teacherId = teacher.getLong("Id") ?: return null
if (data.teacherList[teacherId] == null) {
data.teacherList[teacherId] = Teacher(
data.profileId,
teacherId,
teacher.getString("Name") ?: "",
teacher.getString("Surname") ?: ""
)
}
return teacherId
}
fun getSubjectId(json: JsonObject?, key: String): Long? {
val subject = json.getJsonObject(key)
val subjectId = subject.getLong("Id") ?: return null
if (data.subjectList[subjectId] == null) {
data.subjectList[subjectId] = Subject(
data.profileId,
subjectId,
subject.getString("Name") ?: "",
subject.getString("Kod") ?: ""
)
}
return subjectId
}
fun getTeamId(json: JsonObject?, key: String): Long? {
val team = json.getJsonObject(key)
val teamId = team.getLong("Id") ?: return null
if (data.teamList[teamId] == null) {
var name = team.getString("Shortcut")
?: team.getString("Name")
?: ""
name = "${profile?.studentClassName ?: ""} $name"
data.teamList[teamId] = Team(
data.profileId,
teamId,
name,
Team.TYPE_VIRTUAL,
"${data.schoolCode}:$name",
-1
)
}
return teamId
}
fun getClassId(json: JsonObject?, key: String): Long? {
val team = json.getJsonObject(key)
val teamId = team.getLong("Id") ?: return null
if (data.teamList[teamId] == null) {
val name = data.profile?.studentClassName
?: team.getString("Name")
?: ""
data.teamList[teamId] = Team(
data.profileId,
teamId,
name,
Team.TYPE_CLASS,
"${data.schoolCode}:$name",
-1
)
}
return teamId
}
fun getLessonRange(json: JsonObject?, key: String): LessonRange? {
val timeslot = json.getJsonObject(key)
val position = timeslot.getInt("Position") ?: return null
val start = timeslot.getString("Start") ?: return null
val end = timeslot.getString("End") ?: return null
val lessonRange = LessonRange(
data.profileId,
position,
Time.fromH_m(start),
Time.fromH_m(end)
)
data.lessonRanges[position] = lessonRange
return lessonRange
}
fun getSemester(json: JsonObject?): Int {
val periodId = json.getInt("PeriodId") ?: return 1
return if (periodId == data.semester1Id)
1
else
2
}
inline fun <reified T> apiRequest(
tag: String,
endpoint: String,
method: Int = GET,
payload: JsonElement? = null,
baseUrl: Boolean = false,
firebaseToken: String? = null,
crossinline onSuccess: (json: T, response: Response?) -> Unit
) {
val url = "${if (baseUrl) data.apiUrl else data.fullApiUrl}$endpoint"
d(tag, "Request: Vulcan/Hebe - $url")
val privateKey = data.hebePrivateKey
val publicHash = data.hebePublicHash
if (privateKey == null || publicHash == null) {
data.error(ApiError(TAG, ERROR_LOGIN_DATA_MISSING))
return
}
val timestamp = ZonedDateTime.now(ZoneId.of("GMT"))
val timestampMillis = timestamp.toInstant().toEpochMilli()
val timestampIso = timestamp.format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss"))
val finalPayload = if (payload != null) {
JsonObject(
"AppName" to VULCAN_HEBE_APP_NAME,
"AppVersion" to VULCAN_HEBE_APP_VERSION,
"CertificateId" to publicHash,
"Envelope" to payload,
"FirebaseToken" to (firebaseToken ?: data.app.config.sync.tokenVulcanHebe),
"API" to 1,
"RequestId" to UUID.randomUUID().toString(),
"Timestamp" to timestampMillis,
"TimestampFormatted" to timestampIso
)
} else null
val jsonString = finalPayload?.toString()
val headers = getSignatureHeaders(
publicHash,
privateKey,
jsonString,
endpoint,
timestamp
)
val callback = object : JsonCallbackHandler() {
override fun onSuccess(json: JsonObject?, response: Response?) {
if (json == null) {
data.error(ApiError(TAG, ERROR_RESPONSE_EMPTY)
.withResponse(response)
)
return
}
val status = json.getJsonObject("Status")
if (status?.getInt("Code") != 0) {
data.error(ApiError(tag, ERROR_VULCAN_HEBE_OTHER)
.withResponse(response)
.withApiResponse(json.toString()))
}
val envelope = when (T::class.java) {
JsonObject::class.java -> json.getJsonObject("Envelope")
JsonArray::class.java -> json.getJsonArray("Envelope")
else -> {
data.error(ApiError(tag, ERROR_RESPONSE_EMPTY)
.withResponse(response)
.withApiResponse(json)
)
return
}
}
try {
onSuccess(envelope as T, response)
} catch (e: Exception) {
data.error(ApiError(tag, EXCEPTION_VULCAN_HEBE_REQUEST)
.withResponse(response)
.withThrowable(e)
.withApiResponse(json)
)
}
}
override fun onFailure(response: Response?, throwable: Throwable?) {
data.error(ApiError(tag, ERROR_REQUEST_FAILURE)
.withResponse(response)
.withThrowable(throwable)
)
}
}
Request.builder()
.url(url)
.userAgent(VULCAN_HEBE_USER_AGENT)
.addHeader("vOS", "Android")
.addHeader("vDeviceModel", Build.MODEL)
.addHeader("vAPI", "1")
.apply {
if (data.hebeContext != null)
addHeader("vContext", data.hebeContext)
headers.forEach {
addHeader(it.key, it.value)
}
when (method) {
GET -> get()
POST -> {
post()
setTextBody(jsonString, MediaTypeUtils.APPLICATION_JSON)
}
}
}
.allowErrorCode(HttpURLConnection.HTTP_BAD_REQUEST)
.allowErrorCode(HttpURLConnection.HTTP_FORBIDDEN)
.allowErrorCode(HttpURLConnection.HTTP_UNAUTHORIZED)
.allowErrorCode(HttpURLConnection.HTTP_UNAVAILABLE)
.callback(callback)
.build()
.enqueue()
}
inline fun <reified T> apiGet(
tag: String,
endpoint: String,
query: Map<String, String> = mapOf(),
baseUrl: Boolean = false,
firebaseToken: String? = null,
crossinline onSuccess: (json: T, response: Response?) -> Unit
) {
val queryPath = query.map {
it.key + "=" + URLEncoder.encode(it.value, "UTF-8").replace("+", "%20")
}.join("&")
apiRequest(
tag,
if (query.isNotEmpty()) "$endpoint?$queryPath" else endpoint,
baseUrl = baseUrl,
firebaseToken = firebaseToken,
onSuccess = onSuccess
)
}
inline fun <reified T> apiPost(
tag: String,
endpoint: String,
payload: JsonElement,
baseUrl: Boolean = false,
firebaseToken: String? = null,
crossinline onSuccess: (json: T, response: Response?) -> Unit
) {
apiRequest(
tag,
endpoint,
method = POST,
payload,
baseUrl = baseUrl,
firebaseToken = firebaseToken,
onSuccess = onSuccess
)
}
fun apiGetList(
tag: String,
endpoint: String,
filterType: HebeFilterType? = null,
dateFrom: Date? = null,
dateTo: Date? = null,
lastSync: Long? = null,
folder: Int? = null,
params: Map<String, String> = mapOf(),
includeFilterType: Boolean = true,
onSuccess: (data: List<JsonObject>, response: Response?) -> Unit
) {
val url = if (includeFilterType && filterType != null)
"$endpoint/${filterType.endpoint}"
else endpoint
val query = params.toMutableMap()
when (filterType) {
HebeFilterType.BY_PUPIL -> {
query["unitId"] = data.studentUnitId.toString()
query["pupilId"] = data.studentId.toString()
query["periodId"] = data.studentSemesterId.toString()
}
HebeFilterType.BY_PERSON -> {
query["loginId"] = data.studentLoginId.toString()
}
HebeFilterType.BY_PERIOD -> {
query["periodId"] = data.studentSemesterId.toString()
query["pupilId"] = data.studentId.toString()
}
}
if (dateFrom != null)
query["dateFrom"] = dateFrom.stringY_m_d
if (dateTo != null)
query["dateTo"] = dateTo.stringY_m_d
if (folder != null)
query["folder"] = folder.toString()
query["lastId"] = "-2147483648" // don't ask, it's just Vulcan
query["pageSize"] = "500"
query["lastSyncDate"] = LocalDateTime
.ofInstant(Instant.ofEpochMilli(lastSync ?: 0), ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
apiGet(tag, url, query) { json: JsonArray, response ->
onSuccess(json.map { it.asJsonObject }, response)
}
}
}

View File

@ -0,0 +1,7 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe
enum class HebeFilterType(val endpoint: String) {
BY_PUPIL("byPupil"),
BY_PERSON("byPerson"),
BY_PERIOD("byPeriod")
}

View File

@ -0,0 +1,78 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.VULCAN_HEBE_ENDPOINT_EXAMS
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.ENDPOINT_VULCAN_HEBE_EXAMS
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanHebe
import pl.szczodrzynski.edziennik.data.db.entity.Event
import pl.szczodrzynski.edziennik.data.db.entity.Metadata
import pl.szczodrzynski.edziennik.data.db.entity.SYNC_ALWAYS
class VulcanHebeExams(
override val data: DataVulcan,
override val lastSync: Long?,
val onSuccess: (endpointId: Int) -> Unit
) : VulcanHebe(data, lastSync) {
companion object {
const val TAG = "VulcanHebeExams"
}
init {
apiGetList(
TAG,
VULCAN_HEBE_ENDPOINT_EXAMS,
HebeFilterType.BY_PUPIL,
lastSync = lastSync
) { list, _ ->
list.forEach { exam ->
val id = exam.getLong("Id") ?: return@forEach
val eventDate = getDate(exam, "Deadline") ?: return@forEach
val subjectId = getSubjectId(exam, "Subject") ?: -1
val teacherId = getTeacherId(exam, "Creator") ?: -1
val teamId = getTeamId(exam, "Distribution")
?: getClassId(exam, "Class")
?: data.teamClass?.id
?: -1
val topic = exam.getString("Content")?.trim() ?: ""
val lessonList = data.db.timetableDao().getAllForDateNow(profileId, eventDate)
val startTime = lessonList.firstOrNull { it.subjectId == subjectId }?.startTime
val type = when (exam.getString("Type")) {
"Praca klasowa",
"Sprawdzian" -> Event.TYPE_EXAM
"Kartkówka" -> Event.TYPE_SHORT_QUIZ
else -> Event.TYPE_DEFAULT
}
val eventObject = Event(
profileId = profileId,
id = id,
date = eventDate,
time = startTime,
topic = topic,
color = null,
type = type,
teacherId = teacherId,
subjectId = subjectId,
teamId = teamId
)
data.eventList.add(eventObject)
data.metadataList.add(
Metadata(
profileId,
Metadata.TYPE_EVENT,
id,
profile?.empty ?: true,
profile?.empty ?: true
)
)
}
data.setSyncNext(ENDPOINT_VULCAN_HEBE_EXAMS, SYNC_ALWAYS)
onSuccess(ENDPOINT_VULCAN_HEBE_EXAMS)
}
}
}

View File

@ -0,0 +1,121 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.VULCAN_HEBE_ENDPOINT_GRADES
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.ENDPOINT_VULCAN_HEBE_GRADES
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanHebe
import pl.szczodrzynski.edziennik.data.db.entity.Grade
import pl.szczodrzynski.edziennik.data.db.entity.Metadata
import pl.szczodrzynski.edziennik.data.db.entity.SYNC_ALWAYS
import java.text.DecimalFormat
import kotlin.math.roundToInt
class VulcanHebeGrades(
override val data: DataVulcan,
override val lastSync: Long?,
val onSuccess: (endpointId: Int) -> Unit
) : VulcanHebe(data, lastSync) {
companion object {
const val TAG = "VulcanHebeGrades"
}
init {
apiGetList(
TAG,
VULCAN_HEBE_ENDPOINT_GRADES,
HebeFilterType.BY_PUPIL,
lastSync = lastSync
) { list, _ ->
list.forEach { grade ->
val id = grade.getLong("Id") ?: return@forEach
val column = grade.getJsonObject("Column")
val category = column.getJsonObject("Category")
val categoryText = category.getString("Name")
val teacherId = getTeacherId(grade, "Creator") ?: -1
val subjectId = getSubjectId(column, "Subject") ?: -1
val description = column.getString("Name")
val comment = grade.getString("Comment")
var value = grade.getFloat("Value")
var weight = column.getFloat("Weight") ?: 0.0f
val numerator = grade.getFloat("Numerator ")
val denominator = grade.getFloat("Denominator")
val addedDate = getDateTime(grade, "DateModify")
var finalDescription = ""
var name = when (numerator != null && denominator != null) {
true -> {
value = numerator / denominator
finalDescription += DecimalFormat("#.##").format(numerator) +
"/" + DecimalFormat("#.##").format(denominator)
weight = 0.0f
(value * 100).roundToInt().toString() + "%"
}
else -> {
if (value == null) weight = 0.0f
grade.getString("Content") ?: ""
}
}
comment?.also {
if (name == "") name = it
else finalDescription = (if (finalDescription == "") "" else " ") + it
}
description?.also {
finalDescription = (if (finalDescription == "") "" else " - ") + it
}
val columnColor = column.getInt("Color") ?: 0
val color = if (columnColor == 0)
when (name) {
"1-", "1", "1+" -> 0xffd65757
"2-", "2", "2+" -> 0xff9071b3
"3-", "3", "3+" -> 0xffd2ab24
"4-", "4", "4+" -> 0xff50b6d6
"5-", "5", "5+" -> 0xff2cbd92
"6-", "6", "6+" -> 0xff91b43c
else -> 0xff3D5F9C
}.toInt()
else
columnColor
val gradeObject = Grade(
profileId = profileId,
id = id,
name = name,
type = Grade.TYPE_NORMAL,
value = value ?: 0.0f,
weight = weight,
color = color,
category = categoryText,
description = finalDescription,
comment = null,
semester = getSemester(column),
teacherId = teacherId,
subjectId = subjectId,
addedDate = addedDate
)
data.gradeList.add(gradeObject)
data.metadataList.add(
Metadata(
profileId,
Metadata.TYPE_GRADE,
id,
profile?.empty ?: true,
profile?.empty ?: true
)
)
}
data.setSyncNext(ENDPOINT_VULCAN_HEBE_GRADES, SYNC_ALWAYS)
onSuccess(ENDPOINT_VULCAN_HEBE_GRADES)
}
}
}

View File

@ -0,0 +1,69 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe
import pl.szczodrzynski.edziennik.data.api.VULCAN_HEBE_ENDPOINT_HOMEWORK
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.ENDPOINT_VULCAN_HEBE_HOMEWORK
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanHebe
import pl.szczodrzynski.edziennik.data.db.entity.Event
import pl.szczodrzynski.edziennik.data.db.entity.Metadata
import pl.szczodrzynski.edziennik.data.db.entity.SYNC_ALWAYS
import pl.szczodrzynski.edziennik.getLong
import pl.szczodrzynski.edziennik.getString
class VulcanHebeHomework(
override val data: DataVulcan,
override val lastSync: Long?,
val onSuccess: (endpointId: Int) -> Unit
) : VulcanHebe(data, lastSync) {
companion object {
const val TAG = "VulcanHebeHomework"
}
init {
apiGetList(
TAG,
VULCAN_HEBE_ENDPOINT_HOMEWORK,
HebeFilterType.BY_PUPIL,
lastSync = lastSync
) { list, _ ->
list.forEach { exam ->
val id = exam.getLong("IdHomework") ?: return@forEach
val eventDate = getDate(exam, "Deadline") ?: return@forEach
val subjectId = getSubjectId(exam, "Subject") ?: -1
val teacherId = getTeacherId(exam, "Creator") ?: -1
val teamId = data.teamClass?.id ?: -1
val topic = exam.getString("Content")?.trim() ?: ""
val lessonList = data.db.timetableDao().getAllForDateNow(profileId, eventDate)
val startTime = lessonList.firstOrNull { it.subjectId == subjectId }?.startTime
val eventObject = Event(
profileId = profileId,
id = id,
date = eventDate,
time = startTime,
topic = topic,
color = null,
type = Event.TYPE_HOMEWORK,
teacherId = teacherId,
subjectId = subjectId,
teamId = teamId
)
data.eventList.add(eventObject)
data.metadataList.add(
Metadata(
profileId,
Metadata.TYPE_HOMEWORK,
id,
profile?.empty ?: true,
profile?.empty ?: true
)
)
}
data.setSyncNext(ENDPOINT_VULCAN_HEBE_HOMEWORK, SYNC_ALWAYS)
onSuccess(ENDPOINT_VULCAN_HEBE_HOMEWORK)
}
}
}

View File

@ -0,0 +1,160 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe
import com.google.gson.JsonArray
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_VULCAN
import pl.szczodrzynski.edziennik.data.api.VULCAN_HEBE_ENDPOINT_MAIN
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.ENDPOINT_VULCAN_HEBE_MAIN
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanHebe
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.utils.models.Date
class VulcanHebeMain(
override val data: DataVulcan,
override val lastSync: Long? = null
) : VulcanHebe(data, lastSync) {
companion object {
const val TAG = "VulcanHebeMain"
}
fun getStudents(
profile: Profile?,
profileList: MutableList<Profile>?,
loginStoreId: Int? = null,
firstProfileId: Int? = null,
onEmpty: (() -> Unit)? = null,
onSuccess: () -> Unit
) {
if (profile == null && (profileList == null || loginStoreId == null || firstProfileId == null))
throw IllegalArgumentException()
apiGet(
TAG,
VULCAN_HEBE_ENDPOINT_MAIN,
query = mapOf("lastSyncDate" to "null"),
baseUrl = profile == null
) { students: JsonArray, _ ->
if (students.isEmpty()) {
if (onEmpty != null)
onEmpty()
else
onSuccess()
return@apiGet
}
// safe to assume this will be non-null when creating a profile
var profileId = firstProfileId ?: loginStoreId ?: 1
students.forEach { studentEl ->
val student = studentEl.asJsonObject
val pupil = student.getJsonObject("Pupil")
val studentId = pupil.getInt("Id") ?: return@forEach
// check the student ID in case of not first login
if (profile != null && data.studentId != studentId)
return@forEach
val unit = student.getJsonObject("Unit")
val constituentUnit = student.getJsonObject("ConstituentUnit")
val login = student.getJsonObject("Login")
val periods = student.getJsonArray("Periods")?.map {
it.asJsonObject
} ?: listOf()
val period = periods.firstOrNull {
it.getBoolean("Current", false)
} ?: return@forEach
val periodLevel = period.getInt("Level") ?: return@forEach
val semester1 = periods.firstOrNull {
it.getInt("Level") == periodLevel && it.getInt("Number") == 1
}
val semester2 = periods.firstOrNull {
it.getInt("Level") == periodLevel && it.getInt("Number") == 2
}
val schoolSymbol = unit.getString("Symbol") ?: return@forEach
val schoolShort = unit.getString("Short") ?: return@forEach
val schoolCode = "${data.symbol}_$schoolSymbol"
val studentUnitId = unit.getInt("Id") ?: return@forEach
val studentConstituentId = constituentUnit.getInt("Id") ?: return@forEach
val studentLoginId = login.getInt("Id") ?: return@forEach
//val studentClassId = student.getInt("IdOddzial") ?: return@forEach
val studentClassName = student.getString("ClassDisplay") ?: return@forEach
val studentFirstName = pupil.getString("FirstName") ?: ""
val studentLastName = pupil.getString("Surname") ?: ""
val studentNameLong = "$studentFirstName $studentLastName".fixName()
val studentNameShort = "$studentFirstName ${studentLastName[0]}.".fixName()
val userLogin = login.getString("Value") ?: ""
val studentSemesterId = period.getInt("Id") ?: return@forEach
val studentSemesterNumber = period.getInt("Number") ?: return@forEach
val hebeContext = student.getString("Context")
val isParent = login.getString("LoginRole").equals("opiekun", ignoreCase = true)
val accountName = if (isParent)
login.getString("DisplayName")?.fixName()
else null
val dateSemester1Start = semester1
?.getJsonObject("Start")
?.getString("Date")
?.let { Date.fromY_m_d(it) }
val dateSemester2Start = semester2
?.getJsonObject("Start")
?.getString("Date")
?.let { Date.fromY_m_d(it) }
val dateYearEnd = semester2
?.getJsonObject("End")
?.getString("Date")
?.let { Date.fromY_m_d(it) }
val newProfile = profile ?: Profile(
profileId++,
loginStoreId!!,
LOGIN_TYPE_VULCAN,
studentNameLong,
userLogin,
studentNameLong,
studentNameShort,
accountName
)
newProfile.apply {
this.studentClassName = studentClassName
studentData["symbol"] = data.symbol
studentData["studentId"] = studentId
studentData["studentUnitId"] = studentUnitId
studentData["studentConstituentId"] = studentConstituentId
studentData["studentLoginId"] = studentLoginId
studentData["studentSemesterId"] = studentSemesterId
studentData["studentSemesterNumber"] = studentSemesterNumber
studentData["semester1Id"] = semester1?.getInt("Id") ?: 0
studentData["semester2Id"] = semester2?.getInt("Id") ?: 0
studentData["schoolSymbol"] = schoolSymbol
studentData["schoolShort"] = schoolShort
studentData["schoolName"] = schoolCode
studentData["hebeContext"] = hebeContext
}
dateSemester1Start?.let {
newProfile.dateSemester1Start = it
newProfile.studentSchoolYearStart = it.year
}
dateSemester2Start?.let { newProfile.dateSemester2Start = it }
dateYearEnd?.let { newProfile.dateYearEnd = it }
if (profile != null)
data.setSyncNext(ENDPOINT_VULCAN_HEBE_MAIN, 1 * DAY)
profileList?.add(newProfile)
}
onSuccess()
}
}
}

View File

@ -0,0 +1,247 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe
import com.google.gson.JsonObject
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.VULCAN_HEBE_ENDPOINT_TIMETABLE
import pl.szczodrzynski.edziennik.data.api.VULCAN_HEBE_ENDPOINT_TIMETABLE_CHANGES
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.ENDPOINT_VULCAN_HEBE_TIMETABLE
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanHebe
import pl.szczodrzynski.edziennik.data.db.entity.Lesson
import pl.szczodrzynski.edziennik.data.db.entity.Lesson.Companion.TYPE_CANCELLED
import pl.szczodrzynski.edziennik.data.db.entity.Lesson.Companion.TYPE_CHANGE
import pl.szczodrzynski.edziennik.data.db.entity.Lesson.Companion.TYPE_NORMAL
import pl.szczodrzynski.edziennik.data.db.entity.Lesson.Companion.TYPE_SHIFTED_SOURCE
import pl.szczodrzynski.edziennik.data.db.entity.Lesson.Companion.TYPE_SHIFTED_TARGET
import pl.szczodrzynski.edziennik.data.db.entity.Metadata
import pl.szczodrzynski.edziennik.data.db.entity.SYNC_ALWAYS
import pl.szczodrzynski.edziennik.utils.Utils.d
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.Week
class VulcanHebeTimetable(
override val data: DataVulcan,
override val lastSync: Long?,
val onSuccess: (endpointId: Int) -> Unit
) : VulcanHebe(data, lastSync) {
companion object {
const val TAG = "VulcanHebeTimetable"
}
private val lessonList = mutableListOf<Lesson>()
private val lessonDates = mutableSetOf<Int>()
init {
val previousWeekStart = Week.getWeekStart().stepForward(0, 0, -7)
if (Date.getToday().weekDay > 4) {
previousWeekStart.stepForward(0, 0, 7)
}
val dateFrom = data.arguments
?.getString("weekStart")
?.let { Date.fromY_m_d(it) }
?: previousWeekStart
val dateTo = dateFrom.clone().stepForward(0, 0, 13)
val lastSync = null
apiGetList(
TAG,
VULCAN_HEBE_ENDPOINT_TIMETABLE,
HebeFilterType.BY_PUPIL,
dateFrom = dateFrom,
dateTo = dateTo,
lastSync = lastSync
) { lessons, _ ->
apiGetList(
TAG,
VULCAN_HEBE_ENDPOINT_TIMETABLE_CHANGES,
HebeFilterType.BY_PUPIL,
dateFrom = dateFrom,
dateTo = dateTo,
lastSync = lastSync
) { changes, _ ->
processData(lessons, changes)
// cancel lesson changes when caused by a shift
for (lesson in lessonList) {
if (lesson.type != TYPE_SHIFTED_TARGET)
continue
lessonList.firstOrNull {
it.oldDate == lesson.date
&& it.oldLessonNumber == lesson.lessonNumber
&& it.type == TYPE_CHANGE
}?.let {
it.type = TYPE_CANCELLED
it.date = null
it.lessonNumber = null
it.startTime = null
it.endTime = null
it.subjectId = null
it.teacherId = null
it.teamId = null
it.classroom = null
}
}
// add TYPE_NO_LESSONS to empty dates
val date: Date = dateFrom.clone()
while (date <= dateTo) {
if (!lessonDates.contains(date.value)) {
lessonList.add(Lesson(profileId, date.value.toLong()).apply {
this.type = Lesson.TYPE_NO_LESSONS
this.date = date.clone()
})
}
date.stepForward(0, 0, 1)
}
d(
TAG,
"Clearing lessons between ${dateFrom.stringY_m_d} and ${dateTo.stringY_m_d}"
)
data.lessonList.addAll(lessonList)
data.setSyncNext(ENDPOINT_VULCAN_HEBE_TIMETABLE, SYNC_ALWAYS)
onSuccess(ENDPOINT_VULCAN_HEBE_TIMETABLE)
}
}
}
private fun buildLesson(changes: List<JsonObject>, json: JsonObject): Pair<Lesson, Lesson?>? {
val lesson = Lesson(profileId, -1)
var lessonShift: Lesson? = null
val lessonDate = getDate(json, "Date") ?: return null
val lessonRange = getLessonRange(json, "TimeSlot")
val startTime = lessonRange?.startTime
val endTime = lessonRange?.endTime
val teacherId = getTeacherId(json, "TeacherPrimary")
val classroom = json.getString("Room")
val subjectId = getSubjectId(json, "Subject")
val teamId = getTeamId(json, "Distribution")
?: getClassId(json, "Clazz")
?: data.teamClass?.id
?: -1
val change = json.getJsonObject("Change")
val changeId = change.getInt("Id")
val type = when (change.getInt("Type")) {
1 -> TYPE_CANCELLED
2 -> TYPE_CHANGE
3 -> TYPE_SHIFTED_SOURCE
4 -> TYPE_CANCELLED // TODO: 2021-02-21 add showing cancellation reason
else -> TYPE_NORMAL
}
lesson.type = type
if (type == TYPE_NORMAL) {
lesson.date = lessonDate
lesson.lessonNumber = lessonRange?.lessonNumber
lesson.startTime = startTime
lesson.endTime = endTime
lesson.subjectId = subjectId
lesson.teacherId = teacherId
lesson.teamId = teamId
lesson.classroom = classroom
} else {
lesson.oldDate = lessonDate
lesson.oldLessonNumber = lessonRange?.lessonNumber
lesson.oldStartTime = startTime
lesson.oldEndTime = endTime
lesson.oldSubjectId = subjectId
lesson.oldTeacherId = teacherId
lesson.oldTeamId = teamId
lesson.oldClassroom = classroom
}
if (type == TYPE_CHANGE || type == TYPE_SHIFTED_SOURCE) {
val changeJson = changes.firstOrNull {
it.getInt("Id") == changeId
} ?: return lesson to null
val changeLessonDate = getDate(changeJson, "LessonDate") ?: return lesson to null
val changeLessonRange = getLessonRange(changeJson, "TimeSlot") ?: lessonRange
val changeStartTime = changeLessonRange?.startTime
val changeEndTime = changeLessonRange?.endTime
val changeTeacherId = getTeacherId(changeJson, "TeacherPrimary") ?: teacherId
val changeClassroom = changeJson.getString("Room") ?: classroom
val changeSubjectId = getSubjectId(changeJson, "Subject") ?: subjectId
val changeTeamId = getTeamId(json, "Distribution")
?: getClassId(json, "Clazz")
?: teamId
if (type != TYPE_CHANGE) {
/* lesson shifted */
lessonShift = Lesson(profileId, -1)
lessonShift.type = TYPE_SHIFTED_TARGET
// update source lesson with the target lesson date
lesson.date = changeLessonDate
lesson.lessonNumber = changeLessonRange?.lessonNumber
lesson.startTime = changeStartTime
lesson.endTime = changeEndTime
// update target lesson with the source lesson date
lessonShift.oldDate = lessonDate
lessonShift.oldLessonNumber = lessonRange?.lessonNumber
lessonShift.oldStartTime = startTime
lessonShift.oldEndTime = endTime
}
(if (type == TYPE_CHANGE) lesson else lessonShift)
?.apply {
this.date = changeLessonDate
this.lessonNumber = changeLessonRange?.lessonNumber
this.startTime = changeStartTime
this.endTime = changeEndTime
this.subjectId = changeSubjectId
this.teacherId = changeTeacherId
this.teamId = changeTeamId
this.classroom = changeClassroom
}
}
return lesson to lessonShift
}
private fun processData(lessons: List<JsonObject>, changes: List<JsonObject>) {
lessons.forEach { lessonJson ->
if (lessonJson.getBoolean("Visible") != true)
return@forEach
val lessonPair = buildLesson(changes, lessonJson) ?: return@forEach
val (lessonObject, lessonShift) = lessonPair
when {
lessonShift != null -> lessonShift
lessonObject.type != TYPE_NORMAL -> lessonObject
else -> null
}?.let { lesson ->
val lessonDate = lesson.displayDate ?: return@let
val seen = profile?.empty ?: true || lessonDate < Date.getToday()
data.metadataList.add(
Metadata(
profileId,
Metadata.TYPE_LESSON_CHANGE,
lesson.id,
seen,
seen
)
)
}
lessonObject.id = lessonObject.buildId()
lessonShift?.id = lessonShift?.buildId() ?: -1
lessonList.add(lessonObject)
lessonShift?.let { lessonList.add(it) }
lessonObject.displayDate?.let { lessonDates.add(it.value) }
lessonShift?.displayDate?.let { lessonDates.add(it.value) }
}
}
}

View File

@ -9,9 +9,12 @@ import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanApi
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanHebe
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanWebMain
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.hebe.VulcanHebeMain
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.CufsCertificate
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLoginApi
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.events.FirstLoginFinishedEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
@ -25,6 +28,7 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
private val api = VulcanApi(data, null)
private val web = VulcanWebMain(data, null)
private val hebe = VulcanHebe(data, null)
private val profileList = mutableListOf<Profile>()
private val loginStoreId = data.loginStore.id
private var firstProfileId = loginStoreId
@ -50,12 +54,18 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
checkSymbol(certificate)
}
}
else {
else if (data.loginStore.mode == LOGIN_MODE_VULCAN_API) {
registerDevice {
EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
onSuccess()
}
}
else {
registerDeviceHebe {
EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
onSuccess()
}
}
}
private fun checkSymbol(certificate: CufsCertificate) {
@ -103,7 +113,7 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
data.apiPin = data.apiPin.toMutableMap().also {
it[symbol] = json.getString("PIN")
}
registerDevice(onSuccess)
registerDeviceHebe(onSuccess)
}
}
}
@ -197,4 +207,21 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
}
}
}
private fun registerDeviceHebe(onSuccess: () -> Unit) {
VulcanLoginHebe(data) {
VulcanHebeMain(data).getStudents(
profile = null,
profileList,
loginStoreId,
firstProfileId,
onEmpty = {
EventBus.getDefault()
.postSticky(FirstLoginFinishedEvent(listOf(), data.loginStore))
onSuccess()
},
onSuccess = onSuccess
)
}
}
}

View File

@ -6,6 +6,7 @@ package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_VULCAN_API
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_VULCAN_HEBE
import pl.szczodrzynski.edziennik.data.api.LOGIN_METHOD_VULCAN_WEB_MAIN
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.utils.Utils
@ -54,6 +55,10 @@ class VulcanLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
data.startProgress(R.string.edziennik_progress_login_vulcan_api)
VulcanLoginApi(data) { onSuccess(loginMethodId) }
}
LOGIN_METHOD_VULCAN_HEBE -> {
data.startProgress(R.string.edziennik_progress_login_vulcan_api)
VulcanLoginHebe(data) { onSuccess(loginMethodId) }
}
}
}
}

View File

@ -15,6 +15,7 @@ import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.api.VulcanApiUpdateSemester
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.api.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.utils.Utils.d
import java.net.HttpURLConnection.HTTP_BAD_REQUEST
import java.util.*
@ -190,17 +191,12 @@ class VulcanLoginApi(val data: DataVulcan, val onSuccess: () -> Unit) {
}
}
val deviceId = data.app.deviceId.padStart(16, '0')
val loginStoreId = data.loginStore.id.toString(16).padStart(4, '0')
val symbol = data.symbol?.crc16()?.toString(16)?.take(2) ?: "00"
val uuid =
deviceId.substring(0..7) +
"-" + deviceId.substring(8..11) +
"-" + deviceId.substring(12..15) +
"-" + loginStoreId +
"-" + symbol + "6f72616e7a"
val deviceNameSuffix = " - nie usuwać"
val szkolnyApi = SzkolnyApi(data.app)
val firebaseToken = szkolnyApi.runCatching({
getFirebaseToken("vulcan")
}, onError = {
// screw errors
}) ?: data.app.config.sync.tokenVulcan
Request.builder()
.url("${data.apiUrl}$VULCAN_API_ENDPOINT_CERTIFICATE")
@ -208,8 +204,8 @@ class VulcanLoginApi(val data: DataVulcan, val onSuccess: () -> Unit) {
.addHeader("RequestMobileType", "RegisterDevice")
.addParameter("PIN", data.apiPin[data.symbol])
.addParameter("TokenKey", data.apiToken[data.symbol])
.addParameter("DeviceId", uuid)
.addParameter("DeviceName", VULCAN_API_DEVICE_NAME.take(50 - deviceNameSuffix.length) + deviceNameSuffix)
.addParameter("DeviceId", data.buildDeviceId())
.addParameter("DeviceName", VULCAN_API_DEVICE_NAME)
.addParameter("DeviceNameUser", "")
.addParameter("DeviceDescription", "")
.addParameter("DeviceSystemType", "Android")
@ -220,6 +216,7 @@ class VulcanLoginApi(val data: DataVulcan, val onSuccess: () -> Unit) {
.addParameter("AppVersion", VULCAN_API_APP_VERSION)
.addParameter("RemoteMobileAppVersion", VULCAN_API_APP_VERSION)
.addParameter("RemoteMobileAppName", VULCAN_API_APP_NAME)
.addParameter("FirebaseTokenKey", firebaseToken ?: "")
.postJson()
.allowErrorCode(HTTP_BAD_REQUEST)
.callback(callback)

View File

@ -0,0 +1,106 @@
package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login
import com.google.gson.JsonObject
import io.github.wulkanowy.signer.hebe.generateKeyPair
import pl.szczodrzynski.edziennik.JsonObject
import pl.szczodrzynski.edziennik.data.api.ERROR_LOGIN_DATA_MISSING
import pl.szczodrzynski.edziennik.data.api.VULCAN_API_DEVICE_NAME
import pl.szczodrzynski.edziennik.data.api.VULCAN_HEBE_ENDPOINT_REGISTER_NEW
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanHebe
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.api.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.getString
import pl.szczodrzynski.edziennik.isNotNullNorEmpty
class VulcanLoginHebe(val data: DataVulcan, val onSuccess: () -> Unit) {
companion object {
private const val TAG = "VulcanLoginHebe"
}
init { run {
// i'm sure this does something useful
// not quite sure what, though
if (data.studentSemesterNumber == 1 && data.semester1Id == 0)
data.semester1Id = data.studentSemesterNumber
if (data.studentSemesterNumber == 2 && data.semester2Id == 0)
data.semester2Id = data.studentSemesterNumber
copyFromLoginStore()
if (data.profile != null && data.isApiLoginValid()) {
onSuccess()
}
else {
if (data.symbol.isNotNullNorEmpty() && data.apiToken[data.symbol].isNotNullNorEmpty() && data.apiPin[data.symbol].isNotNullNorEmpty()) {
loginWithToken()
}
else {
data.error(ApiError(TAG, ERROR_LOGIN_DATA_MISSING))
}
}
}}
private fun copyFromLoginStore() {
data.loginStore.data.apply {
// map form inputs to the symbol
if (has("symbol")) {
data.symbol = getString("symbol")
remove("symbol")
}
if (has("deviceToken")) {
data.apiToken = data.apiToken.toMutableMap().also {
it[data.symbol] = getString("deviceToken")
}
remove("deviceToken")
}
if (has("devicePin")) {
data.apiPin = data.apiPin.toMutableMap().also {
it[data.symbol] = getString("devicePin")
}
remove("devicePin")
}
}
}
private fun loginWithToken() {
val szkolnyApi = SzkolnyApi(data.app)
val hebe = VulcanHebe(data, null)
if (data.hebePublicKey == null || data.hebePrivateKey == null || data.hebePublicHash == null) {
val (publicPem, privatePem, publicHash) = generateKeyPair()
data.hebePublicKey = publicPem
data.hebePrivateKey = privatePem
data.hebePublicHash = publicHash
}
val firebaseToken = szkolnyApi.runCatching({
getFirebaseToken("vulcan")
}, onError = {
// screw errors
}) ?: data.app.config.sync.tokenVulcan
hebe.apiPost(
TAG,
VULCAN_HEBE_ENDPOINT_REGISTER_NEW,
payload = JsonObject(
"OS" to "Android",
"PIN" to data.apiPin[data.symbol],
"Certificate" to data.hebePublicKey,
"CertificateType" to "RSA_PEM",
"DeviceModel" to VULCAN_API_DEVICE_NAME,
"SecurityToken" to data.apiToken[data.symbol],
"SelfIdentifier" to data.buildDeviceId(),
"CertificateThumbprint" to data.hebePublicHash
),
baseUrl = true,
firebaseToken = firebaseToken
) { _: JsonObject, _ ->
data.apiToken = data.apiToken.toMutableMap().also {
it[data.symbol] = it[data.symbol]?.substring(0, 3)
}
data.loginStore.removeLoginData("apiPin")
onSuccess()
}
}
}

View File

@ -89,7 +89,7 @@ class VulcanLoginWebMain(val data: DataVulcan, val onSuccess: () -> Unit) {
return true
}
val fsLogin = FSLogin(data.app.http, debug = App.debugMode)
val fsLogin = FSLogin(data.app.http, debug = App.devMode)
fsLogin.performLogin(
realm = realm,
username = data.webUsername ?: data.webEmail ?: return false,

View File

@ -0,0 +1,11 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-9-3.
*/
package pl.szczodrzynski.edziennik.data.api.events
import pl.szczodrzynski.edziennik.data.api.szkolny.response.RegisterAvailabilityStatus
data class RegisterAvailabilityEvent(
val data: Map< String, RegisterAvailabilityStatus>
)

View File

@ -136,7 +136,7 @@ abstract class Data(val app: App, val profile: Profile?, val loginStore: LoginSt
val db: AppDb by lazy { app.db }
init {
if (App.devMode) {
if (App.debugMode) {
fakeLogin = loginStore.hasLoginData("fakeLogin")
}
clear()

View File

@ -12,12 +12,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.szkolny.adapter.DateAdapter
import pl.szczodrzynski.edziennik.data.api.szkolny.adapter.TimeAdapter
import pl.szczodrzynski.edziennik.data.api.szkolny.interceptor.SignatureInterceptor
import pl.szczodrzynski.edziennik.data.api.szkolny.request.*
import pl.szczodrzynski.edziennik.data.api.szkolny.response.ApiResponse
import pl.szczodrzynski.edziennik.data.api.szkolny.response.RegisterAvailabilityStatus
import pl.szczodrzynski.edziennik.data.api.szkolny.response.Update
import pl.szczodrzynski.edziennik.data.api.szkolny.response.WebPushResponse
import pl.szczodrzynski.edziennik.data.db.entity.Event
@ -112,6 +114,22 @@ class SzkolnyApi(val app: App) : CoroutineScope {
*/
@Throws(Exception::class)
private inline fun <reified T> parseResponse(response: Response<ApiResponse<T>>): T {
app.config.update = response.body()?.update?.let { update ->
if (update.versionCode > BuildConfig.VERSION_CODE) {
if (update.updateMandatory
&& EventBus.getDefault().hasSubscriberForEvent(update::class.java)) {
EventBus.getDefault().postSticky(update)
}
update
}
else
null
}
response.body()?.registerAvailability?.let { registerAvailability ->
app.config.sync.registerAvailability = registerAvailability
}
if (response.isSuccessful && response.body()?.success == true) {
if (Unit is T) {
return Unit
@ -335,4 +353,16 @@ class SzkolnyApi(val app: App) : CoroutineScope {
return parseResponse(response)
}
@Throws(Exception::class)
fun getFirebaseToken(registerName: String): String {
val response = api.firebaseToken(registerName).execute()
return parseResponse(response)
}
@Throws(Exception::class)
fun getRegisterAvailability(): Map<String, RegisterAvailabilityStatus> {
val response = api.registerAvailability().execute()
return parseResponse(response)
}
}

View File

@ -35,4 +35,10 @@ interface SzkolnyService {
@GET("appLogin/platforms/{registerName}")
fun appLoginPlatforms(@Path("registerName") registerName: String): Call<ApiResponse<List<LoginInfo.Platform>>>
@GET("firebase/token/{registerName}")
fun firebaseToken(@Path("registerName") registerName: String): Call<ApiResponse<String>>
@GET("registerAvailability")
fun registerAvailability(): Call<ApiResponse<Map<String, RegisterAvailabilityStatus>>>
}

View File

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

View File

@ -10,7 +10,10 @@ data class ApiResponse<T> (
val errors: List<Error>? = null,
val data: T? = null
val data: T? = null,
val update: Update? = null,
val registerAvailability: Map<String, RegisterAvailabilityStatus>? = null
) {
data class Error (val code: String, val reason: String)
}

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-9-2.
*/
package pl.szczodrzynski.edziennik.data.api.szkolny.response
import pl.szczodrzynski.edziennik.BuildConfig
import pl.szczodrzynski.edziennik.DAY
import pl.szczodrzynski.edziennik.currentTimeUnix
data class RegisterAvailabilityStatus(
val available: Boolean,
val name: String?,
val userMessage: Message?,
val nextCheckAt: Long = currentTimeUnix() + 7 * DAY,
val minVersionCode: Int = BuildConfig.VERSION_CODE
) {
data class Message(
val title: String,
val contentShort: String,
val contentLong: String,
val icon: String?,
val image: String?,
val url: String?
)
}

View File

@ -11,5 +11,6 @@ data class Update(
val releaseNotes: String?,
val releaseType: String,
val isOnGooglePlay: Boolean,
val downloadUrl: String?
)
val downloadUrl: String?,
val updateMandatory: Boolean
)

View File

@ -43,7 +43,7 @@ import pl.szczodrzynski.edziennik.data.db.migration.*
LibrusLesson::class,
TimetableManual::class,
Metadata::class
], version = 88)
], version = 89)
@TypeConverters(
ConverterTime::class,
ConverterDate::class,
@ -173,7 +173,8 @@ abstract class AppDb : RoomDatabase() {
Migration85(),
Migration86(),
Migration87(),
Migration88()
Migration88(),
Migration89()
).allowMainThreadQueries().build()
}
}

View File

@ -60,4 +60,10 @@ interface ProfileDao {
@Query("UPDATE profiles SET empty = 0")
fun setAllNotEmpty()
@Query("SELECT * FROM profiles WHERE archiveId = :archiveId AND archived = 1")
fun getArchivesOf(archiveId: Int): List<Profile>
@Query("SELECT * FROM profiles WHERE archiveId = :archiveId AND archived = 0 ORDER BY profileId DESC LIMIT 1")
fun getNotArchivedOf(archiveId: Int): Profile?
}

View File

@ -16,8 +16,7 @@ import androidx.room.Ignore
import com.google.gson.JsonObject
import pl.droidsonroids.gif.GifDrawable
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_EDUDZIENNIK
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_PODLASIE
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.utils.ProfileImageHolder
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.navlib.ImageHolder
@ -28,7 +27,7 @@ import pl.szczodrzynski.navlib.getDrawableFromRes
@Entity(tableName = "profiles", primaryKeys = ["profileId"])
open class Profile(
@ColumnInfo(name = "profileId")
override val id: Int,
override var id: Int, /* needs to be var for ProfileArchiver */
val loginStoreId: Int,
val loginStoreType: Int,
@ -64,6 +63,12 @@ open class Profile(
var empty = true
var archived = false
/**
* A unique ID matching [archived] profiles with current ones
* and vice-versa.
*/
var archiveId: Int? = null
var syncEnabled = true
var enableSharedEvents = true
var registration = REGISTRATION_UNSPECIFIED
@ -85,6 +90,23 @@ open class Profile(
@delegate:Ignore
val currentSemester by lazy { dateToSemester(Date.getToday()) }
fun shouldArchive(): Boolean {
// vulcan hotfix
if (dateYearEnd.month > 6) {
dateYearEnd.month = 6
dateYearEnd.day = 30
}
// fix for when versions <4.3 synced 2020/2021 year dates to older profiles during 2020 Jun-Aug
if (dateSemester1Start.year > studentSchoolYearStart) {
val diff = dateSemester1Start.year - studentSchoolYearStart
dateSemester1Start.year -= diff
dateSemester2Start.year -= diff
dateYearEnd.year -= diff
}
return Date.getToday() >= dateYearEnd && Date.getToday().year > studentSchoolYearStart
}
fun isBeforeYear() = false && Date.getToday() < dateSemester1Start
var disabledNotifications: List<Long>? = null
var lastReceiversSync: Long = 0
@ -105,15 +127,30 @@ open class Profile(
val isParent
get() = accountName != null
val registerName
get() = when (loginStoreType) {
LOGIN_TYPE_LIBRUS -> "librus"
LOGIN_TYPE_VULCAN -> "vulcan"
LOGIN_TYPE_IDZIENNIK -> "idziennik"
LOGIN_TYPE_MOBIDZIENNIK -> "mobidziennik"
LOGIN_TYPE_PODLASIE -> "podlasie"
LOGIN_TYPE_EDUDZIENNIK -> "edudziennik"
else -> null
}
override fun getImageDrawable(context: Context): Drawable {
if (archived) {
return context.getDrawableFromRes(pl.szczodrzynski.edziennik.R.drawable.profile_archived).also {
it.colorFilter = PorterDuffColorFilter(colorFromName(name), PorterDuff.Mode.DST_OVER)
}
}
if (!image.isNullOrEmpty()) {
try {
if (image?.endsWith(".gif", true) == true) {
return GifDrawable(image ?: "")
}
else {
return RoundedBitmapDrawableFactory.create(context.resources, image ?: "")
return if (image?.endsWith(".gif", true) == true) {
GifDrawable(image ?: "")
} else {
RoundedBitmapDrawableFactory.create(context.resources, image ?: "")
//return Drawable.createFromPath(image ?: "") ?: throw Exception()
}
}
@ -125,9 +162,13 @@ open class Profile(
return context.getDrawableFromRes(R.drawable.profile).also {
it.colorFilter = PorterDuffColorFilter(colorFromName(name), PorterDuff.Mode.DST_OVER)
}
}
override fun getImageHolder(context: Context): ImageHolder {
if (archived) {
return ImageHolder(pl.szczodrzynski.edziennik.R.drawable.profile_archived, colorFromName(name))
}
return if (!image.isNullOrEmpty()) {
try {
ProfileImageHolder(image ?: "")

View File

@ -0,0 +1,10 @@
package pl.szczodrzynski.edziennik.data.db.migration
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration89 : Migration(88, 89) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE profiles ADD COLUMN archiveId INTEGER DEFAULT NULL;")
}
}

View File

@ -5,10 +5,13 @@
package pl.szczodrzynski.edziennik.data.firebase
import com.google.gson.JsonParser
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.*
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.events.FeedbackMessageEvent
import pl.szczodrzynski.edziennik.data.api.events.RegisterAvailabilityEvent
import pl.szczodrzynski.edziennik.data.api.szkolny.response.RegisterAvailabilityStatus
import pl.szczodrzynski.edziennik.data.api.szkolny.response.Update
import pl.szczodrzynski.edziennik.data.api.task.PostNotifications
import pl.szczodrzynski.edziennik.data.db.entity.*
@ -50,6 +53,16 @@ class SzkolnyAppFirebase(val app: App, val profiles: List<Profile>, val message:
val message = app.gson.fromJson(message.data.getString("message"), FeedbackMessage::class.java) ?: return@launch
feedbackMessage(message)
}
"registerAvailability" -> launch {
val data = app.gson.fromJson<Map<String, RegisterAvailabilityStatus>>(
message.data.getString("registerAvailability"),
object: TypeToken<Map<String, RegisterAvailabilityStatus>>(){}.type
) ?: return@launch
app.config.sync.registerAvailability = data
if (EventBus.getDefault().hasSubscriberForEvent(RegisterAvailabilityEvent::class.java)) {
EventBus.getDefault().postSticky(RegisterAvailabilityEvent(data))
}
}
}
}
}

View File

@ -14,6 +14,7 @@ import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.work.*
import kotlinx.coroutines.*
import org.greenrobot.eventbus.EventBus
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.szkolny.SzkolnyApi
@ -76,7 +77,7 @@ class UpdateWorker(val context: Context, val params: WorkerParameters) : Worker(
try {
val update = overrideUpdate
?: run {
val updates = withContext(Dispatchers.Default) {
withContext(Dispatchers.Default) {
SzkolnyApi(app).runCatching({
getUpdate("beta")
}, {
@ -84,15 +85,25 @@ class UpdateWorker(val context: Context, val params: WorkerParameters) : Worker(
})
} ?: return@run null
if (updates.isEmpty()) {
if (app.config.update == null
|| app.config.update?.versionCode ?: BuildConfig.VERSION_CODE <= BuildConfig.VERSION_CODE) {
app.config.update = null
Toast.makeText(app, app.getString(R.string.notification_no_update), Toast.LENGTH_SHORT).show()
return@run null
}
updates[0]
app.config.update
} ?: return
app.config.update = update
if (update.versionCode <= BuildConfig.VERSION_CODE) {
app.config.update = null
return
}
if (EventBus.getDefault().hasSubscriberForEvent(update::class.java)) {
if (!update.updateMandatory) // mandatory updates are posted by the SzkolnyApi
EventBus.getDefault().postSticky(update)
return
}
val notificationIntent = Intent(app, UpdateDownloaderService::class.java)
val pendingIntent = PendingIntent.getService(app, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT)

View File

@ -0,0 +1,96 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-9-3.
*/
package pl.szczodrzynski.edziennik.ui.dialogs
import android.text.method.LinkMovementMethod
import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import coil.api.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.BuildConfig
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.szkolny.response.RegisterAvailabilityStatus
import pl.szczodrzynski.edziennik.databinding.DialogRegisterUnavailableBinding
import pl.szczodrzynski.edziennik.onClick
import pl.szczodrzynski.edziennik.utils.Utils
import kotlin.coroutines.CoroutineContext
class RegisterUnavailableDialog(
val activity: AppCompatActivity,
val status: RegisterAvailabilityStatus,
val onShowListener: ((tag: String) -> Unit)? = null,
val onDismissListener: ((tag: String) -> Unit)? = null
) : CoroutineScope {
companion object {
private const val TAG = "RegisterUnavailableDialog"
}
private lateinit var app: App
private lateinit var dialog: AlertDialog
private val job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
init { run {
if (activity.isFinishing)
return@run
if (status.available && status.minVersionCode <= BuildConfig.VERSION_CODE)
return@run
onShowListener?.invoke(TAG)
app = activity.applicationContext as App
if (!status.available && status.userMessage != null) {
val b = DialogRegisterUnavailableBinding.inflate(LayoutInflater.from(activity), null, false)
b.message = status.userMessage
if (status.userMessage.image != null)
b.image.load(status.userMessage.image)
if (status.userMessage.url != null) {
b.readMore.onClick {
Utils.openUrl(activity, status.userMessage.url)
}
}
b.text.movementMethod = LinkMovementMethod.getInstance()
dialog = MaterialAlertDialogBuilder(activity)
.setView(b.root)
.setPositiveButton(R.string.close) { dialog, _ ->
dialog.dismiss()
}
.setOnDismissListener {
onDismissListener?.invoke(TAG)
}
.show()
return@run
}
val update = app.config.update
if (status.minVersionCode > BuildConfig.VERSION_CODE) {
if (update != null && update.versionCode >= status.minVersionCode) {
UpdateAvailableDialog(activity, update, true, onShowListener, onDismissListener)
}
else {
// this *should* never happen
dialog = MaterialAlertDialogBuilder(activity)
.setTitle(R.string.update_available_title)
.setMessage(R.string.update_available_fallback)
.setPositiveButton(R.string.update_available_button) { dialog, _ ->
Utils.openGooglePlay(activity)
dialog.dismiss()
}
.setCancelable(false)
.setOnDismissListener {
onDismissListener?.invoke(TAG)
}
.show()
}
return@run
}
}}
}

View File

@ -17,7 +17,7 @@ import kotlin.coroutines.CoroutineContext
class ServerMessageDialog(
val activity: AppCompatActivity,
val title: String,
val message: String,
val message: CharSequence,
val onShowListener: ((tag: String) -> Unit)? = null,
val onDismissListener: ((tag: String) -> Unit)? = null
) : CoroutineScope {

View File

@ -0,0 +1,67 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-9-3.
*/
package pl.szczodrzynski.edziennik.ui.dialogs
import android.text.Html
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.szkolny.response.Update
import pl.szczodrzynski.edziennik.sync.UpdateDownloaderService
import kotlin.coroutines.CoroutineContext
class UpdateAvailableDialog(
val activity: AppCompatActivity,
val update: Update,
val mandatory: Boolean = update.updateMandatory,
val onShowListener: ((tag: String) -> Unit)? = null,
val onDismissListener: ((tag: String) -> Unit)? = null
) : CoroutineScope {
companion object {
private const val TAG = "UpdateAvailableDialog"
}
private lateinit var app: App
private lateinit var dialog: AlertDialog
private val job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
init { run {
if (activity.isFinishing)
return@run
if (update.versionCode <= BuildConfig.VERSION_CODE)
return@run
onShowListener?.invoke(TAG)
app = activity.applicationContext as App
dialog = MaterialAlertDialogBuilder(activity)
.setTitle(R.string.update_available_title)
.setMessage(
R.string.update_available_format,
BuildConfig.VERSION_NAME,
update.versionName,
update.releaseNotes?.let { Html.fromHtml(it) } ?: "---"
)
.setPositiveButton(R.string.update_available_button) { dialog, _ ->
activity.startService(Intent(app, UpdateDownloaderService::class.java))
dialog.dismiss()
}
.also {
if (!mandatory)
it.setNeutralButton(R.string.update_available_later, null)
}
.setCancelable(!mandatory)
.setOnDismissListener {
onDismissListener?.invoke(TAG)
}
.show()
}}
}

View File

@ -193,7 +193,7 @@ class EventDetailsDialog(
b.goToTimetableButton.attachToastHint(R.string.hint_go_to_timetable)
// RE-DOWNLOAD
b.downloadButton.isVisible = App.debugMode
b.downloadButton.isVisible = App.devMode
b.downloadButton.onClick {
EdziennikTask.eventGet(event.profileId, event).enqueue(activity)
}

View File

@ -57,7 +57,7 @@ class GradeDetailsDialog(
b.grade = grade
b.weightText = manager.getWeightString(app, grade)
b.commentVisible = false
b.devMode = App.debugMode
b.devMode = App.devMode
b.gradeName.setTextColor(if (ColorUtils.calculateLuminance(gradeColor) > 0.3) 0xaa000000.toInt() else 0xccffffff.toInt())
b.gradeName.background.setTintColor(gradeColor)

View File

@ -78,7 +78,7 @@ class LessonDetailsDialog(
)
}
if (App.debugMode)
if (App.devMode)
b.lessonId.visibility = View.VISIBLE
update()

View File

@ -55,7 +55,7 @@ class AttendanceDetailsDialog(
val attendanceColor = manager.getAttendanceColor(attendance)
b.attendance = attendance
b.devMode = App.debugMode
b.devMode = App.devMode
b.attendanceName.setTextColor(if (ColorUtils.calculateLuminance(attendanceColor) > 0.3) 0xaa000000.toInt() else 0xccffffff.toInt())
b.attendanceName.background.setTintColor(attendanceColor)

View File

@ -167,7 +167,7 @@ class CrashActivity : AppCompatActivity(), CoroutineScope {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
clipboard?.apply {
val clip = ClipData.newPlainText(getString(R.string.customactivityoncrash_error_activity_error_details_clipboard_label), errorInformation)
primaryClip = clip
setPrimaryClip(clip)
Toast.makeText(this@CrashActivity, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
}

View File

@ -17,6 +17,7 @@ import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.db.entity.Event
import pl.szczodrzynski.edziennik.databinding.LabFragmentBinding
import pl.szczodrzynski.edziennik.ui.modules.base.lazypager.LazyFragment
import pl.szczodrzynski.edziennik.utils.TextInputDropDown
import pl.szczodrzynski.fslogin.decode
import kotlin.coroutines.CoroutineContext
@ -62,6 +63,21 @@ class LabPageFragment : LazyFragment(), CoroutineScope {
app.db.eventDao().getRawNow("UPDATE events SET homeworkBody = NULL WHERE profileId = ${App.profileId}")
}
b.unarchive.onClick {
app.profile.archived = false
app.profile.archiveId = null
app.profileSave()
}
val profiles = app.db.profileDao().allNow
b.profile.clear()
b.profile += profiles.map { TextInputDropDown.Item(it.id.toLong(), "${it.id} ${it.name} archived ${it.archived}", tag = it) }
b.profile.select(app.profileId.toLong())
b.profile.setOnChangeListener {
activity.loadProfile(it.id.toInt())
return@setOnChangeListener true
}
val colorSecondary = android.R.attr.textColorSecondary.resolveAttr(activity)
startCoroutineTimer(500L, 300L) {
val text = app.cookieJar.sessionCookies

View File

@ -45,7 +45,7 @@ class ErrorDetailsDialog(
listOf(
it.getStringReason(activity).asBoldSpannable().asColoredSpannable(R.attr.colorOnBackground.resolveAttr(activity)),
activity.getString(R.string.error_unknown_format, it.errorCode, it.tag),
if (App.debugMode)
if (App.devMode)
it.throwable?.stackTraceString ?: it.throwable?.localizedMessage
else
it.throwable?.localizedMessage

View File

@ -71,9 +71,14 @@ class GradesListFragment : Fragment(), CoroutineScope {
val adapter = GradesAdapter(activity)
var firstRun = true
app.db.gradeDao().getAllOrderBy(App.profileId, app.gradesManager.getOrderByString()).observe(this@GradesListFragment, Observer { items -> this@GradesListFragment.launch {
app.db.gradeDao().getAllOrderBy(App.profileId, app.gradesManager.getOrderByString()).observe(this@GradesListFragment, Observer { grades -> this@GradesListFragment.launch {
if (!isAdded) return@launch
val items = when {
app.config.forProfile().grades.hideSticksFromOld && App.devMode -> grades.filter { it.value != 1.0f }
else -> grades
}
// load & configure the adapter
adapter.items = withContext(Dispatchers.Default) { processGrades(items) }
if (items.isNotNullNorEmpty() && b.list.adapter == null) {

View File

@ -33,9 +33,10 @@ class CardItemTouchHelperCallback(private val cardAdapter: HomeCardAdapter, priv
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
removeCard(viewHolder.adapterPosition)
cardAdapter.items.removeAt(viewHolder.adapterPosition)
cardAdapter.notifyItemRemoved(viewHolder.adapterPosition)
val position = viewHolder.adapterPosition
removeCard(position, cardAdapter)
cardAdapter.items.removeAt(position)
cardAdapter.notifyItemRemoved(position)
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {

View File

@ -21,17 +21,11 @@ import androidx.recyclerview.widget.RecyclerViewAccessibilityDelegate
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial.Icon
import com.mikepenz.iconics.typeface.library.szkolny.font.SzkolnyFont
import kotlinx.coroutines.*
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.MainActivity
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
import pl.szczodrzynski.edziennik.databinding.FragmentHomeBinding
import pl.szczodrzynski.edziennik.onClick
import pl.szczodrzynski.edziennik.ui.dialogs.home.StudentNumberDialog
import pl.szczodrzynski.edziennik.ui.modules.home.cards.HomeEventsCard
import pl.szczodrzynski.edziennik.ui.modules.home.cards.HomeGradesCard
import pl.szczodrzynski.edziennik.ui.modules.home.cards.HomeLuckyNumberCard
import pl.szczodrzynski.edziennik.ui.modules.home.cards.HomeTimetableCard
import pl.szczodrzynski.edziennik.ui.modules.home.cards.*
import pl.szczodrzynski.edziennik.utils.Themes
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetPrimaryItem
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetSeparatorItem
@ -44,8 +38,8 @@ class HomeFragment : Fragment(), CoroutineScope {
fun swapCards(fromPosition: Int, toPosition: Int, cardAdapter: HomeCardAdapter): Boolean {
val fromCard = cardAdapter.items[fromPosition]
val toCard = cardAdapter.items[toPosition]
if (fromCard.id == 100 || toCard.id == 100) {
// debug card is not swappable
if (fromCard.id >= 100 || toCard.id >= 100) {
// debug & archive cards are not swappable
return false
}
cardAdapter.items[fromPosition] = cardAdapter.items[toPosition]
@ -53,18 +47,26 @@ class HomeFragment : Fragment(), CoroutineScope {
cardAdapter.notifyItemMoved(fromPosition, toPosition)
val homeCards = App.config.forProfile().ui.homeCards.toMutableList()
val fromPair = homeCards[fromPosition]
homeCards[fromPosition] = homeCards[toPosition]
homeCards[toPosition] = fromPair
val fromIndex = homeCards.indexOfFirst { it.cardId == fromCard.id }
val toIndex = homeCards.indexOfFirst { it.cardId == toCard.id }
val fromPair = homeCards[fromIndex]
homeCards[fromIndex] = homeCards[toIndex]
homeCards[toIndex] = fromPair
App.config.forProfile().ui.homeCards = homeCards
return true
}
fun removeCard(position: Int) {
fun removeCard(position: Int, cardAdapter: HomeCardAdapter) {
val homeCards = App.config.forProfile().ui.homeCards.toMutableList()
if (position >= homeCards.size)
return
homeCards.removeAt(position)
val card = cardAdapter.items[position]
if (card.id >= 100) {
// debug & archive cards are not removable
//cardAdapter.notifyDataSetChanged()
return
}
homeCards.removeAll { it.cardId == card.id }
App.config.forProfile().ui.homeCards = homeCards
}
}
@ -150,16 +152,26 @@ class HomeFragment : Fragment(), CoroutineScope {
val items = mutableListOf<HomeCard>()
cards.mapNotNullTo(items) {
@Suppress("USELESS_CAST")
when (it.cardId) {
HomeCard.CARD_LUCKY_NUMBER -> HomeLuckyNumberCard(it.cardId, app, activity, this, app.profile)
HomeCard.CARD_TIMETABLE -> HomeTimetableCard(it.cardId, app, activity, this, app.profile)
HomeCard.CARD_GRADES -> HomeGradesCard(it.cardId, app, activity, this, app.profile)
HomeCard.CARD_EVENTS -> HomeEventsCard(it.cardId, app, activity, this, app.profile)
else -> null
}
} as HomeCard?
}
//if (App.devMode)
// items += HomeDebugCard(100, app, activity, this, app.profile)
if (app.profile.archived)
items.add(0, HomeArchiveCard(101, app, activity, this, app.profile))
val status = app.config.sync.registerAvailability[app.profile.registerName]
val update = app.config.update
if (update != null && update.versionCode > BuildConfig.VERSION_CODE
|| status != null && (!status.available || status.minVersionCode > BuildConfig.VERSION_CODE)) {
items.add(0, HomeAvailabilityCard(102, app, activity, this, app.profile))
}
val adapter = HomeCardAdapter(items)
val itemTouchHelper = ItemTouchHelper(CardItemTouchHelperCallback(adapter, b.refreshLayout))

View File

@ -0,0 +1,79 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-8-25.
*/
package pl.szczodrzynski.edziennik.ui.modules.home.cards
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.plusAssign
import androidx.core.view.setMargins
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.*
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.databinding.CardHomeArchiveBinding
import pl.szczodrzynski.edziennik.ui.modules.home.HomeCard
import pl.szczodrzynski.edziennik.ui.modules.home.HomeCardAdapter
import pl.szczodrzynski.edziennik.ui.modules.home.HomeFragment
import kotlin.coroutines.CoroutineContext
class HomeArchiveCard(
override val id: Int,
val app: App,
val activity: MainActivity,
val fragment: HomeFragment,
val profile: Profile
) : HomeCard, CoroutineScope {
companion object {
private const val TAG = "HomeArchiveCard"
}
private var job: Job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
override fun bind(position: Int, holder: HomeCardAdapter.ViewHolder) {
holder.root.removeAllViews()
val b = CardHomeArchiveBinding.inflate(LayoutInflater.from(holder.root.context))
b.root.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
setMargins(8.dp)
}
holder.root += b.root
b.homeArchiveText.setText(
R.string.home_archive_text,
profile.studentSchoolYearStart,
profile.studentSchoolYearStart + 1
)
b.homeArchiveClose.onClick {
launch {
val profile = profile.archiveId?.let {
withContext(Dispatchers.IO) {
app.db.profileDao().getNotArchivedOf(it)
}
}
if (profile == null) {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.home_archive_close_no_target_title)
.setMessage(R.string.home_archive_close_no_target_text, this@HomeArchiveCard.profile.name)
.setPositiveButton(R.string.ok) { _, _ ->
activity.drawer.profileSelectionOpen()
activity.drawer.open()
}
.show()
return@launch
}
activity.loadProfile(profile)
}
}
holder.root.onClick {
activity.loadTarget(MainActivity.DRAWER_ITEM_AGENDA)
}
}
override fun unbind(position: Int, holder: HomeCardAdapter.ViewHolder) = Unit
}

View File

@ -0,0 +1,93 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-9-3.
*/
package pl.szczodrzynski.edziennik.ui.modules.home.cards
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.text.HtmlCompat
import androidx.core.view.isVisible
import androidx.core.view.plusAssign
import androidx.core.view.setMargins
import coil.api.load
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.db.entity.Profile
import pl.szczodrzynski.edziennik.databinding.CardHomeAvailabilityBinding
import pl.szczodrzynski.edziennik.sync.UpdateDownloaderService
import pl.szczodrzynski.edziennik.ui.dialogs.RegisterUnavailableDialog
import pl.szczodrzynski.edziennik.ui.dialogs.UpdateAvailableDialog
import pl.szczodrzynski.edziennik.ui.modules.home.HomeCard
import pl.szczodrzynski.edziennik.ui.modules.home.HomeCardAdapter
import pl.szczodrzynski.edziennik.ui.modules.home.HomeFragment
import kotlin.coroutines.CoroutineContext
class HomeAvailabilityCard(
override val id: Int,
val app: App,
val activity: MainActivity,
val fragment: HomeFragment,
val profile: Profile
) : HomeCard, CoroutineScope {
companion object {
private const val TAG = "HomeAvailabilityCard"
}
private var job: Job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
override fun bind(position: Int, holder: HomeCardAdapter.ViewHolder) {
holder.root.removeAllViews()
val b = CardHomeAvailabilityBinding.inflate(LayoutInflater.from(holder.root.context))
b.root.layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
setMargins(8.dp)
}
holder.root += b.root
val status = app.config.sync.registerAvailability[profile.registerName]
val update = app.config.update
if (update == null && status == null)
return
var onInfoClick = { _: View -> }
if (status != null && !status.available && status.userMessage != null) {
b.homeAvailabilityTitle.text = HtmlCompat.fromHtml(status.userMessage.title, HtmlCompat.FROM_HTML_MODE_LEGACY)
b.homeAvailabilityText.text = HtmlCompat.fromHtml(status.userMessage.contentShort, HtmlCompat.FROM_HTML_MODE_LEGACY)
b.homeAvailabilityUpdate.isVisible = false
b.homeAvailabilityIcon.setImageResource(R.drawable.ic_sync)
if (status.userMessage.icon != null)
b.homeAvailabilityIcon.load(status.userMessage.icon)
onInfoClick = {
RegisterUnavailableDialog(activity, status)
}
}
else if (update != null && update.versionCode > BuildConfig.VERSION_CODE) {
b.homeAvailabilityTitle.setText(R.string.home_availability_title)
b.homeAvailabilityText.setText(R.string.home_availability_text, update.versionName)
b.homeAvailabilityUpdate.isVisible = true
b.homeAvailabilityIcon.setImageResource(R.drawable.ic_update)
onInfoClick = {
UpdateAvailableDialog(activity, update)
}
}
b.homeAvailabilityUpdate.onClick {
if (update == null)
return@onClick
activity.startService(Intent(app, UpdateDownloaderService::class.java))
}
b.homeAvailabilityInfo.onClick(onInfoClick)
holder.root.onClick(onInfoClick)
}
override fun unbind(position: Int, holder: HomeCardAdapter.ViewHolder) = Unit
}

View File

@ -25,7 +25,6 @@ import kotlin.coroutines.CoroutineContext
class LoginActivity : AppCompatActivity(), CoroutineScope {
companion object {
private const val TAG = "LoginActivity"
var thisOneIsTricky = 0
}
private val app: App by lazy { applicationContext as App }
@ -42,6 +41,8 @@ class LoginActivity : AppCompatActivity(), CoroutineScope {
val profiles = mutableListOf<LoginSummaryAdapter.Item>()
val loginStores = mutableListOf<LoginStore>()
fun getRootView() = b.root
override fun onBackPressed() {
val destination = nav.currentDestination ?: run {
nav.navigateUp()
@ -55,6 +56,11 @@ class LoginActivity : AppCompatActivity(), CoroutineScope {
return
if (destination.id == R.id.loginFinishFragment)
return
// eggs
if (destination.id == R.id.loginPrizeFragment) {
finish()
return
}
if (destination.id == R.id.loginChooserFragment && loginStores.isEmpty()) {
setResult(Activity.RESULT_CANCELED)
finish()
@ -79,8 +85,6 @@ class LoginActivity : AppCompatActivity(), CoroutineScope {
super.onCreate(savedInstanceState)
setTheme(R.style.AppTheme_Light)
thisOneIsTricky = -1
navOptions = NavOptions.Builder()
.setEnterAnim(R.anim.slide_in_right)
.setExitAnim(R.anim.slide_out_left)

View File

@ -60,7 +60,8 @@ class LoginChooserAdapter(
private val onClickListener = View.OnClickListener { view ->
val model = view.getTag(R.string.tag_key_model)
if (model is LoginInfo.Register && model.loginModes.size == 1) {
if (model is LoginInfo.Register
&& model.loginModes.count { App.devMode || !it.isDevOnly } == 1) {
onModeClick?.invoke(model, model.loginModes.first())
return@OnClickListener
}
@ -85,7 +86,9 @@ class LoginChooserAdapter(
if (model.state == STATE_CLOSED) {
val subItems = model.items
val subItems = model.items.filter {
App.devMode || !it.isDevOnly
}
model.state = STATE_OPENED
items.addAll(position + 1, subItems)

View File

@ -4,28 +4,37 @@
package pl.szczodrzynski.edziennik.ui.modules.login
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.Animation
import android.view.animation.RotateAnimation
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.Bundle
import pl.szczodrzynski.edziennik.R
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.*
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.data.api.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.databinding.LoginChooserFragmentBinding
import pl.szczodrzynski.edziennik.onClick
import pl.szczodrzynski.edziennik.ui.dialogs.RegisterUnavailableDialog
import pl.szczodrzynski.edziennik.ui.modules.feedback.FeedbackActivity
import pl.szczodrzynski.edziennik.utils.SimpleDividerItemDecoration
import kotlin.coroutines.CoroutineContext
class LoginChooserFragment : Fragment(), CoroutineScope {
companion object {
private const val TAG = "LoginChooserFragment"
// eggs
var isRotated = false
}
private lateinit var app: App
@ -47,26 +56,33 @@ class LoginChooserFragment : Fragment(), CoroutineScope {
return b.root
}
@SuppressLint("SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (!isAdded) return
val adapter = LoginChooserAdapter(activity) { loginType, loginMode ->
if (loginMode.isPlatformSelection) {
nav.navigate(R.id.loginPlatformListFragment, Bundle(
"loginType" to loginType.loginType,
"loginMode" to loginMode.loginMode
), activity.navOptions)
return@LoginChooserAdapter
}
nav.navigate(R.id.loginFormFragment, Bundle(
"loginType" to loginType.loginType,
"loginMode" to loginMode.loginMode
), activity.navOptions)
}
val adapter = LoginChooserAdapter(activity, this::onLoginModeClicked)
LoginInfo.chooserList = LoginInfo.chooserList
?: LoginInfo.list.toMutableList<Any>()
?: LoginInfo.list.toMutableList()
// eggs
if (isRotated) {
isRotated = false
LoginFormFragment.wantEggs = false
LoginInfo.chooserList = LoginInfo.list.toMutableList()
val anim = RotateAnimation(
180f,
0f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
)
anim.interpolator = AccelerateDecelerateInterpolator()
anim.duration = 500
anim.fillAfter = true
activity.getRootView().startAnimation(anim)
}
adapter.items = LoginInfo.chooserList!!
b.list.adapter = adapter
@ -76,6 +92,85 @@ class LoginChooserFragment : Fragment(), CoroutineScope {
addItemDecoration(SimpleDividerItemDecoration(context))
}
b.helpButton.onClick {
startActivity(Intent(activity, FeedbackActivity::class.java))
}
// eggs
b.footnoteText.onClick {
if (!LoginFormFragment.wantEggs || isRotated)
return@onClick
val text = b.subtitleText.text.toString()
if (text.endsWith(".."))
b.subtitleText.text = text.substring(0, text.length - 2)
else
b.subtitleText.text = "$text..."
}
var clickCount = 0
val color = R.color.md_blue_500.resolveColor(app)
val hsv = FloatArray(3)
Color.colorToHSV(color, hsv)
val hueOriginal = hsv[0]
b.subtitleText.onClick {
if (isRotated)
return@onClick
val text = b.subtitleText.text.toString()
if (text.endsWith("..") && !text.endsWith("...")) {
clickCount++
}
if (clickCount == 5) {
val anim = ValueAnimator.ofFloat(0f, 1f)
anim.duration = 5000
anim.addUpdateListener {
hsv[0] = hueOriginal + it.animatedFraction * 3f * 360f
hsv[0] = hsv[0] % 360f
b.topLogo.drawable.setTintColor(Color.HSVToColor(Color.alpha(color), hsv))
}
anim.start()
}
}
b.topLogo.onClick {
if (clickCount != 5 || isRotated) {
clickCount = 0
return@onClick
}
isRotated = true
val anim = RotateAnimation(
0f,
180f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
)
anim.interpolator = AccelerateDecelerateInterpolator()
anim.duration = 2000
anim.fillAfter = true
activity.getRootView().startAnimation(anim)
b.list.smoothScrollToPosition(0)
adapter.items.add(
LoginInfo.Register(
loginType = 74,
internalName = "eggs",
registerName = R.string.eggs,
registerLogo = R.drawable.face_1,
loginModes = listOf(
LoginInfo.Mode(
loginMode = 0,
name = 0,
icon = 0,
guideText = 0,
credentials = listOf(),
errorCodes = mapOf()
)
)
)
)
adapter.notifyItemInserted(adapter.items.size - 1)
}
when {
activity.loginStores.isNotEmpty() -> {
// we are navigated here from LoginSummary
@ -96,4 +191,79 @@ class LoginChooserFragment : Fragment(), CoroutineScope {
}
}
}
private fun onLoginModeClicked(
loginType: LoginInfo.Register,
loginMode: LoginInfo.Mode
) {
if (loginType.internalName == "eggs") {
nav.navigate(R.id.loginEggsFragment, null, activity.navOptions)
return
}
launch {
if (!checkAvailability(loginType.loginType))
return@launch
if (loginMode.isTesting || loginMode.isDevOnly) {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.login_chooser_testing_title)
.setMessage(R.string.login_chooser_testing_text)
.setPositiveButton(R.string.ok) { _, _ ->
navigateToLoginMode(loginType, loginMode)
}
.setNegativeButton(R.string.cancel, null)
.show()
return@launch
}
navigateToLoginMode(loginType, loginMode)
}
}
private fun navigateToLoginMode(loginType: LoginInfo.Register, loginMode: LoginInfo.Mode) {
if (loginMode.isPlatformSelection) {
nav.navigate(R.id.loginPlatformListFragment, Bundle(
"loginType" to loginType.loginType,
"loginMode" to loginMode.loginMode
), activity.navOptions)
return
}
nav.navigate(R.id.loginFormFragment, Bundle(
"loginType" to loginType.loginType,
"loginMode" to loginMode.loginMode
), activity.navOptions)
}
private suspend fun checkAvailability(loginType: Int): Boolean {
when (loginType) {
LOGIN_TYPE_LIBRUS -> "librus"
LOGIN_TYPE_VULCAN -> "vulcan"
LOGIN_TYPE_IDZIENNIK -> "idziennik"
LOGIN_TYPE_MOBIDZIENNIK -> "mobidziennik"
LOGIN_TYPE_PODLASIE -> "podlasie"
LOGIN_TYPE_EDUDZIENNIK -> "edudziennik"
else -> null
}?.let { registerName ->
var status = app.config.sync.registerAvailability[registerName]
if (status == null || status.nextCheckAt < currentTimeUnix()) {
withContext(Dispatchers.IO) {
val api = SzkolnyApi(app)
api.runCatching(activity) {
val availability = getRegisterAvailability()
app.config.sync.registerAvailability = availability
status = availability[registerName]
}
}
}
if (status?.available != true) {
if (status != null)
RegisterUnavailableDialog(activity, status!!)
return false
}
}
return true
}
}

View File

@ -0,0 +1,116 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-10-18.
*/
package pl.szczodrzynski.edziennik.ui.modules.login
import android.annotation.SuppressLint
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.Animation
import android.view.animation.RotateAnimation
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.fragment.app.Fragment
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.BuildConfig
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.md5
import kotlin.coroutines.CoroutineContext
class LoginEggsFragment : Fragment(), CoroutineScope {
companion object {
private const val TAG = "LoginEggsFragment"
}
private lateinit var app: App
private lateinit var activity: LoginActivity
private lateinit var view: ViewGroup
private lateinit var webView: WebView
private val nav by lazy { activity.nav }
private val job: Job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
// local/private variables go here
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as LoginActivity?) ?: return null
context ?: return null
app = activity.application as App
webView = WebView(activity)
view = FrameLayout(activity)
view.addView(webView)
return view
}
@SuppressLint("SetJavaScriptEnabled", "AddJavascriptInterface")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (!isAdded) return
if (!LoginChooserFragment.isRotated) {
nav.navigateUp()
return
}
val anim = RotateAnimation(
180f,
0f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
)
anim.interpolator = AccelerateDecelerateInterpolator()
anim.duration = 10
anim.fillAfter = true
activity.getRootView().startAnimation(anim)
webView.apply {
settings.apply {
javaScriptEnabled = true
}
addJavascriptInterface(object : Any() {
@Suppress("NAME_SHADOWING")
@JavascriptInterface
fun getPrize() {
val anim = RotateAnimation(
0f,
180f,
Animation.RELATIVE_TO_SELF,
0.5f,
Animation.RELATIVE_TO_SELF,
0.5f
)
anim.interpolator = AccelerateDecelerateInterpolator()
anim.duration = 10
anim.fillAfter = true
activity.getRootView().startAnimation(anim)
nav.navigate(R.id.loginPrizeFragment, null, activity.navOptions)
}
}, "EggInterface")
loadUrl("https://szkolny.eu/game/runner.html")
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
val deviceId = app.deviceId.md5()
val version = BuildConfig.VERSION_NAME
val js = """initPage("$deviceId", true, "$version");"""
webView.evaluateJavascript(js) {}
}
}
}
}
}
}

View File

@ -22,8 +22,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.databinding.LoginFormCheckboxItemBinding
import pl.szczodrzynski.edziennik.databinding.LoginFormFieldItemBinding
import pl.szczodrzynski.edziennik.databinding.LoginFormFragmentBinding
import pl.szczodrzynski.edziennik.databinding.LoginFormItemBinding
import pl.szczodrzynski.navlib.colorAttr
import java.util.*
import kotlin.coroutines.CoroutineContext
@ -31,6 +32,8 @@ import kotlin.coroutines.CoroutineContext
class LoginFormFragment : Fragment(), CoroutineScope {
companion object {
private const val TAG = "LoginFormFragment"
// eggs
var wantEggs = false
}
private lateinit var app: App
@ -75,33 +78,53 @@ class LoginFormFragment : Fragment(), CoroutineScope {
b.subTitle.text = platformName ?: app.getString(mode.name)
b.text.text = platformGuideText ?: app.getString(mode.guideText)
val credentials = mutableMapOf<LoginInfo.Credential, LoginFormItemBinding>()
val credentials = mutableMapOf<LoginInfo.BaseCredential, Any>()
for (credential in mode.credentials) {
if (platformFormFields?.contains(credential.keyName) == false)
continue
val b = LoginFormItemBinding.inflate(layoutInflater)
b.textLayout.hint = app.getString(credential.name)
if (credential.hideText) {
b.textEdit.inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD
b.textLayout.endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
if (credential is LoginInfo.FormField) {
val b = LoginFormFieldItemBinding.inflate(layoutInflater)
b.textLayout.hint = app.getString(credential.name)
if (credential.hideText) {
b.textEdit.inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD
b.textLayout.endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
}
b.textEdit.addTextChangedListener {
b.textLayout.error = null
}
b.textEdit.id = credential.name
b.textEdit.setText(arguments?.getString(credential.keyName) ?: "")
b.textLayout.startIconDrawable = IconicsDrawable(activity)
.icon(credential.icon)
.sizeDp(24)
.paddingDp(2)
.colorAttr(activity, R.attr.colorOnBackground)
this.b.formContainer.addView(b.root)
credentials[credential] = b
}
b.textEdit.addTextChangedListener {
b.textLayout.error = null
if (credential is LoginInfo.FormCheckbox) {
val b = LoginFormCheckboxItemBinding.inflate(layoutInflater)
b.checkbox.text = app.getString(credential.name)
b.checkbox.onChange { _, isChecked ->
b.errorText.text = null
// eggs
if (register.internalName == "podlasie") {
wantEggs = !isChecked
}
}
if (arguments?.containsKey(credential.keyName) == true) {
b.checkbox.isChecked = arguments?.getBoolean(credential.keyName) == true
}
this.b.formContainer.addView(b.root)
credentials[credential] = b
}
b.textEdit.id = credential.name
b.textEdit.setText(arguments?.getString(credential.keyName) ?: "")
b.textLayout.startIconDrawable = IconicsDrawable(activity)
.icon(credential.icon)
.sizeDp(24)
.paddingDp(2)
.colorAttr(activity, R.attr.colorOnBackground)
this.b.formContainer.addView(b.root)
credentials[credential] = b
}
activity.lastError?.let { error ->
@ -109,7 +132,12 @@ class LoginFormFragment : Fragment(), CoroutineScope {
startCoroutineTimer(delayMillis = 200L) {
for (credential in credentials) {
credential.key.errorCodes[error.errorCode]?.let {
credential.value.textLayout.error = app.getString(it)
(credential.value as? LoginFormFieldItemBinding)?.let { b ->
b.textLayout.error = app.getString(it)
}
(credential.value as? LoginFormCheckboxItemBinding)?.let { b ->
b.errorText.text = app.getString(it)
}
return@startCoroutineTimer
}
}
@ -127,7 +155,7 @@ class LoginFormFragment : Fragment(), CoroutineScope {
"loginMode" to loginMode
)
if (App.devMode && b.fakeLogin.isChecked) {
if (App.debugMode && b.fakeLogin.isChecked) {
payload.putBoolean("fakeLogin", true)
}
@ -137,35 +165,42 @@ class LoginFormFragment : Fragment(), CoroutineScope {
var hasErrors = false
credentials.forEach { (credential, b) ->
var text = b.textEdit.text?.toString() ?: return@forEach
if (!credential.hideText)
text = text.trim()
if (credential is LoginInfo.FormField && b is LoginFormFieldItemBinding) {
var text = b.textEdit.text?.toString() ?: return@forEach
if (!credential.hideText)
text = text.trim()
if (credential.caseMode == LoginInfo.Credential.CaseMode.UPPER_CASE)
text = text.toUpperCase(Locale.getDefault())
if (credential.caseMode == LoginInfo.Credential.CaseMode.LOWER_CASE)
text = text.toLowerCase(Locale.getDefault())
if (credential.caseMode == LoginInfo.FormField.CaseMode.UPPER_CASE)
text = text.toUpperCase(Locale.getDefault())
if (credential.caseMode == LoginInfo.FormField.CaseMode.LOWER_CASE)
text = text.toLowerCase(Locale.getDefault())
credential.stripTextRegex?.let {
text = text.replace(it.toRegex(), "")
credential.stripTextRegex?.let {
text = text.replace(it.toRegex(), "")
}
b.textEdit.setText(text)
if (credential.isRequired && text.isBlank()) {
b.textLayout.error = app.getString(credential.emptyText)
hasErrors = true
return@forEach
}
if (!text.matches(credential.validationRegex.toRegex())) {
b.textLayout.error = app.getString(credential.invalidText)
hasErrors = true
return@forEach
}
payload.putString(credential.keyName, text)
arguments?.putString(credential.keyName, text)
}
b.textEdit.setText(text)
if (credential.isRequired && text.isBlank()) {
b.textLayout.error = app.getString(credential.emptyText)
hasErrors = true
return@forEach
if (credential is LoginInfo.FormCheckbox && b is LoginFormCheckboxItemBinding) {
val checked = b.checkbox.isChecked
payload.putBoolean(credential.keyName, checked)
arguments?.putBoolean(credential.keyName, checked)
}
if (!text.matches(credential.validationRegex.toRegex())) {
b.textLayout.error = app.getString(credential.invalidText)
hasErrors = true
return@forEach
}
payload.putString(credential.keyName, text)
arguments?.putString(credential.keyName, text)
}
if (hasErrors)

View File

@ -15,7 +15,7 @@ import pl.szczodrzynski.edziennik.ui.modules.grades.models.ExpandableItemModel
object LoginInfo {
private fun getEmailCredential(keyName: String) = Credential(
private fun getEmailCredential(keyName: String) = FormField(
keyName = keyName,
name = R.string.login_hint_email,
icon = CommunityMaterial.Icon.cmd_at,
@ -24,9 +24,9 @@ object LoginInfo {
errorCodes = mapOf(),
isRequired = true,
validationRegex = "([\\w.\\-_+]+)?\\w+@[\\w-_]+(\\.\\w+)+",
caseMode = Credential.CaseMode.LOWER_CASE
caseMode = FormField.CaseMode.LOWER_CASE
)
private fun getPasswordCredential(keyName: String) = Credential(
private fun getPasswordCredential(keyName: String) = FormField(
keyName = keyName,
name = R.string.login_hint_password,
icon = CommunityMaterial.Icon2.cmd_lock_outline,
@ -62,7 +62,7 @@ object LoginInfo {
ERROR_CAPTCHA_LIBRUS_PORTAL to R.string.error_3001_reason
)
),
Mode(
/*Mode(
loginMode = LOGIN_MODE_LIBRUS_SYNERGIA,
name = R.string.login_mode_librus_synergia,
icon = R.drawable.login_mode_librus_synergia,
@ -86,7 +86,7 @@ object LoginInfo {
ERROR_LOGIN_LIBRUS_API_INVALID_LOGIN to R.string.login_error_incorrect_login_or_password,
ERROR_LOGIN_LIBRUS_API_INVALID_REQUEST to R.string.login_error_incorrect_login_or_password
)
),
),*/
Mode(
loginMode = LOGIN_MODE_LIBRUS_JST,
name = R.string.login_mode_librus_jst,
@ -94,7 +94,7 @@ object LoginInfo {
hintText = R.string.login_mode_librus_jst_hint,
guideText = R.string.login_mode_librus_jst_guide,
credentials = listOf(
Credential(
FormField(
keyName = "accountCode",
name = R.string.login_hint_token,
icon = CommunityMaterial.Icon.cmd_code_braces,
@ -103,9 +103,9 @@ object LoginInfo {
errorCodes = mapOf(),
isRequired = true,
validationRegex = "[A-Z0-9_]+",
caseMode = Credential.CaseMode.UPPER_CASE
caseMode = FormField.CaseMode.UPPER_CASE
),
Credential(
FormField(
keyName = "accountPin",
name = R.string.login_hint_pin,
icon = CommunityMaterial.Icon2.cmd_lock,
@ -114,7 +114,7 @@ object LoginInfo {
errorCodes = mapOf(),
isRequired = true,
validationRegex = "[a-z0-9_]+",
caseMode = Credential.CaseMode.LOWER_CASE
caseMode = FormField.CaseMode.LOWER_CASE
)
),
errorCodes = mapOf(
@ -138,7 +138,7 @@ object LoginInfo {
guideText = R.string.login_mode_vulcan_api_guide,
isRecommended = true,
credentials = listOf(
Credential(
FormField(
keyName = "deviceToken",
name = R.string.login_hint_token,
icon = CommunityMaterial.Icon.cmd_code_braces,
@ -149,9 +149,9 @@ object LoginInfo {
),
isRequired = true,
validationRegex = "[A-Z0-9]{5,12}",
caseMode = Credential.CaseMode.UPPER_CASE
caseMode = FormField.CaseMode.UPPER_CASE
),
Credential(
FormField(
keyName = "symbol",
name = R.string.login_hint_symbol,
icon = CommunityMaterial.Icon2.cmd_school,
@ -162,9 +162,9 @@ object LoginInfo {
),
isRequired = true,
validationRegex = "[a-z0-9_-]+",
caseMode = Credential.CaseMode.LOWER_CASE
caseMode = FormField.CaseMode.LOWER_CASE
),
Credential(
FormField(
keyName = "devicePin",
name = R.string.login_hint_pin,
icon = CommunityMaterial.Icon2.cmd_lock,
@ -175,7 +175,59 @@ object LoginInfo {
),
isRequired = true,
validationRegex = "[0-9]+",
caseMode = Credential.CaseMode.LOWER_CASE
caseMode = FormField.CaseMode.LOWER_CASE
)
),
errorCodes = mapOf(
ERROR_LOGIN_VULCAN_EXPIRED_TOKEN to R.string.login_error_expired_token
)
),
Mode(
loginMode = LOGIN_MODE_VULCAN_HEBE,
name = R.string.login_mode_vulcan_api,
icon = R.drawable.login_mode_vulcan_hebe,
hintText = R.string.login_mode_vulcan_api_hint,
guideText = R.string.login_mode_vulcan_api_guide,
isTesting = true,
credentials = listOf(
FormField(
keyName = "deviceToken",
name = R.string.login_hint_token,
icon = CommunityMaterial.Icon.cmd_code_braces,
emptyText = R.string.login_error_no_token,
invalidText = R.string.login_error_incorrect_token,
errorCodes = mapOf(
ERROR_LOGIN_VULCAN_INVALID_TOKEN to R.string.login_error_incorrect_token
),
isRequired = true,
validationRegex = "[A-Z0-9]{5,12}",
caseMode = FormField.CaseMode.UPPER_CASE
),
FormField(
keyName = "symbol",
name = R.string.login_hint_symbol,
icon = CommunityMaterial.Icon2.cmd_school,
emptyText = R.string.login_error_no_symbol,
invalidText = R.string.login_error_incorrect_symbol,
errorCodes = mapOf(
ERROR_LOGIN_VULCAN_INVALID_SYMBOL to R.string.login_error_incorrect_symbol
),
isRequired = true,
validationRegex = "[a-z0-9_-]+",
caseMode = FormField.CaseMode.LOWER_CASE
),
FormField(
keyName = "devicePin",
name = R.string.login_hint_pin,
icon = CommunityMaterial.Icon2.cmd_lock,
emptyText = R.string.login_error_no_pin,
invalidText = R.string.login_error_incorrect_pin,
errorCodes = mapOf(
ERROR_LOGIN_VULCAN_INVALID_PIN to R.string.login_error_incorrect_pin
),
isRequired = true,
validationRegex = "[0-9]+",
caseMode = FormField.CaseMode.LOWER_CASE
)
),
errorCodes = mapOf(
@ -188,11 +240,11 @@ object LoginInfo {
icon = R.drawable.login_mode_vulcan_web,
hintText = R.string.login_mode_vulcan_web_hint,
guideText = R.string.login_mode_vulcan_web_guide,
isTesting = true,
isDevOnly = true,
isPlatformSelection = true,
credentials = listOf(
getEmailCredential("webEmail"),
Credential(
FormField(
keyName = "webUsername",
name = R.string.login_hint_username,
icon = CommunityMaterial.Icon.cmd_account_outline,
@ -201,7 +253,7 @@ object LoginInfo {
errorCodes = mapOf(),
isRequired = true,
validationRegex = "[A-Z]{7}[0-9]+",
caseMode = Credential.CaseMode.UPPER_CASE
caseMode = FormField.CaseMode.UPPER_CASE
),
getPasswordCredential("webPassword")
),
@ -222,7 +274,7 @@ object LoginInfo {
hintText = R.string.login_mode_mobidziennik_web_hint,
guideText = R.string.login_mode_mobidziennik_web_guide,
credentials = listOf(
Credential(
FormField(
keyName = "username",
name = R.string.login_hint_login_email,
icon = CommunityMaterial.Icon.cmd_account_outline,
@ -231,9 +283,9 @@ object LoginInfo {
errorCodes = mapOf(),
isRequired = true,
validationRegex = "^[a-z0-9_\\-@+.]+$",
caseMode = Credential.CaseMode.LOWER_CASE
caseMode = FormField.CaseMode.LOWER_CASE
),
Credential(
FormField(
keyName = "password",
name = R.string.login_hint_password,
icon = CommunityMaterial.Icon2.cmd_lock_outline,
@ -246,7 +298,7 @@ object LoginInfo {
validationRegex = ".*",
hideText = true
),
Credential(
FormField(
keyName = "serverName",
name = R.string.login_hint_address,
icon = CommunityMaterial.Icon2.cmd_web,
@ -257,7 +309,7 @@ object LoginInfo {
),
isRequired = true,
validationRegex = "^[a-z0-9_\\-]+\$",
caseMode = Credential.CaseMode.LOWER_CASE
caseMode = FormField.CaseMode.LOWER_CASE
)
),
errorCodes = mapOf(
@ -280,7 +332,7 @@ object LoginInfo {
hintText = R.string.login_mode_idziennik_web_hint,
guideText = R.string.login_mode_idziennik_web_guide,
credentials = listOf(
Credential(
FormField(
keyName = "schoolName",
name = R.string.login_hint_school_name,
icon = CommunityMaterial.Icon2.cmd_school,
@ -291,9 +343,9 @@ object LoginInfo {
),
isRequired = true,
validationRegex = "^[a-z0-9_\\-.]+$",
caseMode = Credential.CaseMode.LOWER_CASE
caseMode = FormField.CaseMode.LOWER_CASE
),
Credential(
FormField(
keyName = "username",
name = R.string.login_hint_username,
icon = CommunityMaterial.Icon.cmd_account_outline,
@ -302,7 +354,7 @@ object LoginInfo {
errorCodes = mapOf(),
isRequired = true,
validationRegex = "^[a-z0-9_\\-.]+$",
caseMode = Credential.CaseMode.LOWER_CASE
caseMode = FormField.CaseMode.LOWER_CASE
),
getPasswordCredential("password")
),
@ -346,7 +398,7 @@ object LoginInfo {
icon = R.drawable.login_mode_podlasie_api,
guideText = R.string.login_mode_podlasie_api_guide,
credentials = listOf(
Credential(
FormField(
keyName = "apiToken",
name = R.string.login_hint_token,
icon = CommunityMaterial.Icon2.cmd_lock_outline,
@ -355,7 +407,15 @@ object LoginInfo {
errorCodes = mapOf(),
isRequired = true,
validationRegex = "[a-zA-Z0-9]{10}",
caseMode = Credential.CaseMode.UNCHANGED
caseMode = FormField.CaseMode.UNCHANGED
),
FormCheckbox(
keyName = "logoutDevices",
name = R.string.login_podlasie_logout_devices,
checked = false,
errorCodes = mapOf(
ERROR_LOGIN_PODLASIE_API_DEVICE_LIMIT to R.string.error_602_reason
)
)
),
errorCodes = mapOf()
@ -390,9 +450,10 @@ object LoginInfo {
val isRecommended: Boolean = false,
val isTesting: Boolean = false,
val isDevOnly: Boolean = false,
val isPlatformSelection: Boolean = false,
val credentials: List<Credential>,
val credentials: List<BaseCredential>,
val errorCodes: Map<Int, Int>
)
@ -409,11 +470,18 @@ object LoginInfo {
val apiData: JsonObject
)
data class Credential(
val keyName: String,
open class BaseCredential(
open val keyName: String,
@StringRes
open val name: Int,
open val errorCodes: Map<Int, Int>
)
data class FormField(
override val keyName: String,
@StringRes
val name: Int,
override val name: Int,
val icon: IIcon,
@StringRes
val placeholder: Int? = null,
@ -421,7 +489,7 @@ object LoginInfo {
val emptyText: Int,
@StringRes
val invalidText: Int,
val errorCodes: Map<Int, Int>,
override val errorCodes: Map<Int, Int>,
@StringRes
val hintText: Int? = null,
@ -430,10 +498,18 @@ object LoginInfo {
val caseMode: CaseMode = CaseMode.UNCHANGED,
val hideText: Boolean = false,
val stripTextRegex: String? = null
) {
) : BaseCredential(keyName, name, errorCodes) {
enum class CaseMode { UNCHANGED, UPPER_CASE, LOWER_CASE }
}
data class FormCheckbox(
override val keyName: String,
@StringRes
override val name: Int,
val checked: Boolean = false,
override val errorCodes: Map<Int, Int> = mapOf()
) : BaseCredential(keyName, name, errorCodes)
var chooserList: MutableList<Any>? = null
var platformList: MutableMap<Int, List<Platform>> = mutableMapOf()
}

View File

@ -47,6 +47,7 @@ class LoginPlatformListFragment : Fragment(), CoroutineScope {
}
private lateinit var timeoutJob: Job
private lateinit var adapter: LoginPlatformAdapter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (!isAdded) return
@ -57,12 +58,7 @@ class LoginPlatformListFragment : Fragment(), CoroutineScope {
val loginMode = arguments?.getInt("loginMode") ?: return
val mode = register.loginModes.firstOrNull { it.loginMode == loginMode } ?: return
timeoutJob = startCoroutineTimer(5000L) {
b.timeoutText.isVisible = true
timeoutJob.cancel()
}
val adapter = LoginPlatformAdapter(activity) { platform ->
adapter = LoginPlatformAdapter(activity) { platform ->
nav.navigate(R.id.loginFormFragment, Bundle(
"loginType" to platform.loginType,
"loginMode" to platform.loginMode,
@ -73,7 +69,30 @@ class LoginPlatformListFragment : Fragment(), CoroutineScope {
), activity.navOptions)
}
loadPlatforms(register, mode)
b.reloadButton.isVisible = App.devMode
b.reloadButton.onClick {
LoginInfo.platformList.remove(mode.name)
loadPlatforms(register, mode)
}
b.list.apply {
setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
addItemDecoration(SimpleDividerItemDecoration(context))
}
}
private fun loadPlatforms(register: LoginInfo.Register, mode: LoginInfo.Mode) {
launch {
timeoutJob = startCoroutineTimer(5000L) {
b.timeoutText.isVisible = true
timeoutJob.cancel()
}
b.loadingLayout.isVisible = true
b.list.isVisible = false
b.reloadButton.isEnabled = false
val platforms = LoginInfo.platformList[mode.name]
?: run {
api.runCatching(activity) {
@ -87,14 +106,11 @@ class LoginPlatformListFragment : Fragment(), CoroutineScope {
adapter.items = platforms
b.list.adapter = adapter
b.list.apply {
setHasFixedSize(true)
layoutManager = LinearLayoutManager(context)
addItemDecoration(SimpleDividerItemDecoration(context))
}
timeoutJob.cancel()
b.loadingLayout.isVisible = false
b.list.isVisible = true
b.reloadButton.isEnabled = true
}
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright (c) Kuba Szczodrzyński 2020-10-18.
*/
package pl.szczodrzynski.edziennik.ui.modules.login
import android.os.Bundle
import android.os.Process
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import coil.api.load
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.databinding.LoginPrizeFragmentBinding
import pl.szczodrzynski.edziennik.onClick
import kotlin.coroutines.CoroutineContext
import kotlin.system.exitProcess
class LoginPrizeFragment : Fragment(), CoroutineScope {
companion object {
private const val TAG = "LoginPrizeFragment"
}
private lateinit var app: App
private lateinit var activity: LoginActivity
private lateinit var b: LoginPrizeFragmentBinding
private val nav by lazy { activity.nav }
private val job: Job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
// local/private variables go here
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as LoginActivity?) ?: return null
context ?: return null
app = activity.application as App
b = LoginPrizeFragmentBinding.inflate(inflater)
return b.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
b.button.load("https://szkolny.eu/game/button.png")
b.button.onClick {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.are_you_sure)
.setMessage(R.string.dev_mode_enable_warning)
.setPositiveButton(R.string.yes) { _, _ ->
app.config.debugMode = true
App.devMode = true
MaterialAlertDialogBuilder(activity)
.setTitle("Restart")
.setMessage("Wymagany restart aplikacji")
.setPositiveButton(R.string.ok) { _, _ ->
Process.killProcess(Process.myPid())
Runtime.getRuntime().exit(0)
exitProcess(0)
}
.setCancelable(false)
.show()
}
.setNegativeButton(R.string.no) { _, _ ->
app.config.debugMode = false
App.devMode = false
activity.finish()
}
.show()
}
}
}

View File

@ -53,6 +53,10 @@ class LoginSyncFragment : Fragment(), CoroutineScope {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
EventBus.getDefault().removeStickyEvent(ApiTaskAllFinishedEvent::class.java)
EventBus.getDefault().removeStickyEvent(ApiTaskErrorEvent::class.java)
val profiles = activity.profiles.filter { it.isSelected }.map { it.profile }
val loginStores = activity.loginStores.filter { store -> profiles.any { it.loginStoreId == store.id } }
@ -87,6 +91,7 @@ class LoginSyncFragment : Fragment(), CoroutineScope {
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun onSyncFinishedEvent(event: ApiTaskAllFinishedEvent) {
EventBus.getDefault().removeStickyEvent(event)
nav.navigate(R.id.loginFinishFragment, finishArguments, activity.navOptions)
}

View File

@ -10,7 +10,10 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.databinding.LoginChooserModeItemBinding
import pl.szczodrzynski.edziennik.resolveColor
import pl.szczodrzynski.edziennik.setTintColor
import pl.szczodrzynski.edziennik.ui.modules.grades.viewholder.BindableViewHolder
import pl.szczodrzynski.edziennik.ui.modules.login.LoginChooserAdapter
import pl.szczodrzynski.edziennik.ui.modules.login.LoginInfo
@ -34,6 +37,19 @@ class ModeViewHolder(
b.description.isVisible = true
b.description.setText(item.hintText)
}
b.hint.isVisible = false
b.badge.isVisible = item.isRecommended || item.isDevOnly || item.isTesting
if (item.isRecommended) {
b.badge.setText(R.string.login_chooser_mode_recommended)
b.badge.background.setTintColor(R.color.md_blue_300.resolveColor(app))
}
if (item.isTesting) {
b.badge.setText(R.string.login_chooser_mode_testing)
b.badge.background.setTintColor(R.color.md_yellow_300.resolveColor(app))
}
if (item.isDevOnly) {
b.badge.setText(R.string.login_chooser_mode_dev_only)
b.badge.background.setTintColor(R.color.md_red_300.resolveColor(app))
}
}
}

View File

@ -108,7 +108,7 @@ class MessageFragment : Fragment(), CoroutineScope {
.setNegativeButton(R.string.cancel, null)
.show()
}
b.downloadButton.isVisible = App.debugMode
b.downloadButton.isVisible = App.devMode
b.downloadButton.onClick {
EdziennikTask.messageGet(App.profileId, message).enqueue(activity)
}

View File

@ -581,7 +581,7 @@ public class SettingsNewFragment extends MaterialAboutFragment {
syncCardIntervalItem.setChecked(app.getConfig().getSync().getEnabled());
syncCardIntervalItem.setOnClickAction(() -> {
List<CharSequence> intervalNames = new ArrayList<>();
if (App.Companion.getDevMode() && false) {
if (App.Companion.getDebugMode() && false) {
intervalNames.add(ExtensionsKt.plural(activity, R.plurals.time_till_seconds, 30));
intervalNames.add(ExtensionsKt.plural(activity, R.plurals.time_till_minutes, 2));
}
@ -593,7 +593,7 @@ public class SettingsNewFragment extends MaterialAboutFragment {
intervalNames.add(ExtensionsKt.plural(activity, R.plurals.time_till_hours, 3));
intervalNames.add(ExtensionsKt.plural(activity, R.plurals.time_till_hours, 4));
List<Integer> intervals = new ArrayList<>();
if (App.Companion.getDevMode() && false) {
if (App.Companion.getDebugMode() && false) {
intervals.add(30);
intervals.add(2 * 60);
}
@ -1059,6 +1059,24 @@ public class SettingsNewFragment extends MaterialAboutFragment {
);
}
if (App.Companion.getDevMode()) {
items.add(
new MaterialAboutSwitchItem(
getString(R.string.settings_register_hide_sticks_from_old),
null,
new IconicsDrawable(activity)
.icon(CommunityMaterial.Icon2.cmd_numeric_1_box_outline)
.size(IconicsSize.dp(iconSizeDp))
.color(IconicsColor.colorInt(iconColor))
)
.setChecked(app.getConfig().forProfile().getGrades().getHideSticksFromOld())
.setOnChangeAction((isChecked, tag) -> {
app.getConfig().forProfile().getGrades().setHideSticksFromOld(isChecked);
return true;
})
);
}
}
return items;
}
@ -1245,7 +1263,7 @@ public class SettingsNewFragment extends MaterialAboutFragment {
})
.build());*/
if (App.Companion.getDebugMode()) {
if (App.Companion.getDevMode()) {
items.add(new MaterialAboutActionItem.Builder()
.text(R.string.settings_about_crash_text)
.subText(R.string.settings_about_crash_subtext)

View File

@ -108,7 +108,7 @@ public class Utils {
public static List<String> debugLog = new ArrayList<>();
public static void d(String TAG, String message) {
if (App.Companion.getDebugMode()) {
if (App.Companion.getDevMode()) {
HyperLog.d("Szkolny/"+TAG, message);
//debugLog.add(TAG+": "+message);
}

View File

@ -112,7 +112,9 @@ public class Date implements Comparable<Date> {
Calendar c = Calendar.getInstance();
c.set(Integer.parseInt(dateTime.substring(0, 4)), Integer.parseInt(dateTime.substring(5, 7)) - 1, Integer.parseInt(dateTime.substring(8, 10)), Integer.parseInt(dateTime.substring(11, 13)), Integer.parseInt(dateTime.substring(14, 16)), Integer.parseInt(dateTime.substring(17, 19)));
c.set(Calendar.MILLISECOND, 0);
c.setTimeZone(TimeZone.getTimeZone("UTC"));
if (dateTime.endsWith("Z")) {
c.setTimeZone(TimeZone.getTimeZone("UTC"));
}
return c.getTimeInMillis();
}
catch (Exception e) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,19 @@
<!--
~ Copyright (c) Kuba Szczodrzyński 2020-8-25.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="128dp"
android:height="128dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M54,19H10v33c0,2.209 1.791,4 4,4h36c2.209,0 4,-1.791 4,-4V19z"
android:fillColor="#ffb86b"/>
<path
android:pathData="m54,22h-44c-1.657,0 -3,-1.343 -3,-3v-5c0,-1.657 1.343,-3 3,-3h44c1.657,0 3,1.343 3,3v5c0,1.657 -1.343,3 -3,3z"
android:fillColor="#ffa54a"/>
<path
android:pathData="m37,32h-10c-1.65,0 -3,-1.35 -3,-3s1.35,-3 3,-3h10c1.65,0 3,1.35 3,3s-1.35,3 -3,3z"
android:fillColor="#69707e"/>
</vector>

View File

@ -0,0 +1,11 @@
<!--
~ Copyright (c) Kuba Szczodrzyński 2020-9-3.
-->
<vector android:height="128dp" android:viewportHeight="64"
android:viewportWidth="64" android:width="128dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#1a6ee2" android:pathData="m29.583,48.966c1.329,1.347 3.505,1.347 4.834,0l10.917,-11.06c1.466,-1.484 0.353,-3.906 -1.795,-3.906h-5.539v-8.5c0,-1.375 -1.125,-2.5 -2.5,-2.5h-7c-1.375,0 -2.5,1.125 -2.5,2.5v8.5h-5.539c-2.147,0 -3.26,2.422 -1.795,3.906z"/>
<path android:fillColor="#2aa7ed" android:pathData="m28.5,12h7c1.375,0 2.5,-1.125 2.5,-2.5s-1.125,-2.5 -2.5,-2.5h-7c-1.375,0 -2.5,1.125 -2.5,2.5s1.125,2.5 2.5,2.5z"/>
<path android:fillColor="#2082e6" android:pathData="m28.5,20h7c1.375,0 2.5,-1.125 2.5,-2.5s-1.125,-2.5 -2.5,-2.5h-7c-1.375,0 -2.5,1.125 -2.5,2.5s1.125,2.5 2.5,2.5z"/>
<path android:fillColor="#1762df" android:pathData="m54,55.5c0,1.375 -1.125,2.5 -2.5,2.5h-39c-1.375,0 -2.5,-1.125 -2.5,-2.5s1.125,-2.5 2.5,-2.5h39c1.375,0 2.5,1.125 2.5,2.5z"/>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show More