From 27e49b10fd50fd142f66801f472aa32f3aad2c41 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kuba=20Szczodrzy=C5=84ski?= <kuba@szczodrzynski.pl>
Date: Sun, 19 Apr 2020 19:27:27 +0200
Subject: [PATCH] [API] Implement draft Vulcan Web login.

---
 .gitignore                                    |   3 +-
 app/build.gradle                              |   5 +
 .../edziennik/data/api/Constants.kt           |   2 +
 .../edziennik/data/api/Errors.kt              |  11 +
 .../edziennik/data/api/LoginMethods.kt        |   9 +-
 .../edziennik/data/api/Regexes.kt             |   3 +
 .../firstlogin/EdudziennikFirstLogin.kt       |   2 +-
 .../firstlogin/IdziennikFirstLogin.kt         |   2 +-
 .../librus/firstlogin/LibrusFirstLogin.kt     |   6 +-
 .../firstlogin/MobidziennikFirstLogin.kt      |   2 +-
 .../data/api/edziennik/vulcan/DataVulcan.kt   | 172 ++++++++---
 .../api/edziennik/vulcan/data/VulcanApi.kt    |   4 +-
 .../edziennik/vulcan/data/VulcanWebMain.kt    | 277 ++++++++++++++++++
 .../vulcan/data/api/VulcanApiTimetable.kt     |   2 +-
 .../vulcan/firstlogin/VulcanFirstLogin.kt     | 100 ++++++-
 .../edziennik/vulcan/login/CufsCertificate.kt |  21 ++
 .../api/edziennik/vulcan/login/VulcanLogin.kt |   5 +
 .../edziennik/vulcan/login/VulcanLoginApi.kt  | 125 ++++++--
 .../vulcan/login/VulcanLoginWebMain.kt        | 123 ++++++++
 .../edziennik/ui/modules/login/LoginInfo.kt   |   2 +-
 .../ui/modules/login/LoginProgressFragment.kt |   2 +-
 .../ui/modules/login/LoginSyncFragment.kt     |   2 +-
 .../edziennik/utils/models/Date.java          |   7 +-
 app/src/main/res/values-en/strings.xml        |   3 +
 app/src/main/res/values/strings.xml           |   7 +-
 25 files changed, 794 insertions(+), 103 deletions(-)
 create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/VulcanWebMain.kt
 create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/CufsCertificate.kt
 create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLoginWebMain.kt

diff --git a/.gitignore b/.gitignore
index 25786692..1864dd01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,4 +86,5 @@ app/schemas/
 
 signatures/
 
-app/.cxx
\ No newline at end of file
+app/.cxx
+/i18n/
diff --git a/app/build.gradle b/app/build.gradle
index ae884511..57b92b4a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -201,6 +201,11 @@ dependencies {
 
     implementation 'com.qifan.powerpermission:powerpermission:1.0.0'
     implementation 'com.qifan.powerpermission:powerpermission-coroutines:1.0.0'
+
+    implementation 'com.github.kuba2k2.FSLogin:lib:master-SNAPSHOT'
+    implementation 'pl.droidsonroids:jspoon:1.3.2'
+    implementation "com.squareup.retrofit2:converter-scalars:2.8.1"
+    implementation "pl.droidsonroids.retrofit2:converter-jspoon:1.3.2"
 }
 repositories {
     mavenCentral()
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Constants.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Constants.kt
index 81cd81cf..38f97834 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Constants.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Constants.kt
@@ -113,5 +113,7 @@ const val VULCAN_API_ENDPOINT_MESSAGES_ADD = "mobile-api/Uczen.v3.Uczen/DodajWia
 const val VULCAN_API_ENDPOINT_PUSH = "mobile-api/Uczen.v3.Uczen/UstawPushToken"
 const val VULCAN_API_ENDPOINT_MESSAGES_ATTACHMENTS = "mobile-api/Uczen.v3.Uczen/WiadomosciZalacznik"
 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 EDUDZIENNIK_USER_AGENT = "Szkolny.eu/${BuildConfig.VERSION_NAME}"
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Errors.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Errors.kt
index 268da24e..1905c733 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Errors.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Errors.kt
@@ -159,6 +159,15 @@ const val ERROR_VULCAN_API_MAINTENANCE                      = 340
 const val ERROR_VULCAN_API_BAD_REQUEST                      = 341
 const val ERROR_VULCAN_API_OTHER                            = 342
 const val ERROR_VULCAN_ATTACHMENT_DOWNLOAD                  = 343
+const val ERROR_VULCAN_WEB_DATA_MISSING                     = 344
+const val ERROR_VULCAN_WEB_429                              = 345
+const val ERROR_VULCAN_WEB_OTHER                            = 346
+const val ERROR_VULCAN_WEB_NO_CERTIFICATE                   = 347
+const val ERROR_VULCAN_WEB_NO_REGISTER                      = 348
+const val ERROR_VULCAN_WEB_CERTIFICATE_EXPIRED              = 349
+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_LOGIN_IDZIENNIK_WEB_INVALID_LOGIN           = 401
 const val ERROR_LOGIN_IDZIENNIK_WEB_INVALID_SCHOOL_NAME     = 402
@@ -209,5 +218,7 @@ const val EXCEPTION_IDZIENNIK_API_REQUEST                   = 914
 const val EXCEPTION_EDUDZIENNIK_WEB_REQUEST                 = 920
 const val EXCEPTION_EDUDZIENNIK_FILE_REQUEST                = 921
 const val ERROR_ONEDRIVE_DOWNLOAD                           = 930
+const val EXCEPTION_VULCAN_WEB_LOGIN                        = 931
+const val EXCEPTION_VULCAN_WEB_REQUEST                      = 932
 
 const val LOGIN_NO_ARGUMENTS                                = 1201
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/LoginMethods.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/LoginMethods.kt
index 66b764cd..527c86b1 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/LoginMethods.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/LoginMethods.kt
@@ -16,6 +16,7 @@ import pl.szczodrzynski.edziennik.data.api.edziennik.mobidziennik.login.Mobidzie
 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.VulcanLoginWebMain
 import pl.szczodrzynski.edziennik.data.api.models.LoginMethod
 
 // librus
@@ -103,11 +104,11 @@ const val LOGIN_METHOD_VULCAN_WEB_OLD = 300
 const val LOGIN_METHOD_VULCAN_WEB_MESSAGES = 400
 const val LOGIN_METHOD_VULCAN_API = 500
 val vulcanLoginMethods = listOf(
-        /*LoginMethod(LOGIN_TYPE_VULCAN, LOGIN_METHOD_VULCAN_WEB_MAIN, VulcanLoginWebMain::class.java)
-                .withIsPossible { _, _ -> false }
+        LoginMethod(LOGIN_TYPE_VULCAN, LOGIN_METHOD_VULCAN_WEB_MAIN, VulcanLoginWebMain::class.java)
+                .withIsPossible { _, loginStore -> loginStore.hasLoginData("webHost") }
                 .withRequiredLoginMethod { _, _ -> LOGIN_METHOD_NOT_NEEDED },
 
-        LoginMethod(LOGIN_TYPE_VULCAN, LOGIN_METHOD_VULCAN_WEB_NEW, VulcanLoginWebNew::class.java)
+        /*LoginMethod(LOGIN_TYPE_VULCAN, LOGIN_METHOD_VULCAN_WEB_NEW, VulcanLoginWebNew::class.java)
                 .withIsPossible { _, _ -> false }
                 .withRequiredLoginMethod { _, _ -> LOGIN_METHOD_VULCAN_WEB_MAIN },
 
@@ -118,7 +119,7 @@ val vulcanLoginMethods = listOf(
         LoginMethod(LOGIN_TYPE_VULCAN, LOGIN_METHOD_VULCAN_API, VulcanLoginApi::class.java)
                 .withIsPossible { _, _ -> true }
                 .withRequiredLoginMethod { _, loginStore ->
-                    if (loginStore.mode == LOGIN_MODE_VULCAN_WEB) LOGIN_METHOD_VULCAN_WEB_NEW else LOGIN_METHOD_NOT_NEEDED
+                    if (loginStore.mode == LOGIN_MODE_VULCAN_WEB) LOGIN_METHOD_VULCAN_WEB_MAIN else LOGIN_METHOD_NOT_NEEDED
                 }
 )
 
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Regexes.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Regexes.kt
index 57dceb0e..5da87c73 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Regexes.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/Regexes.kt
@@ -139,6 +139,9 @@ object Regexes {
     val VULCAN_SHIFT_ANNOTATION by lazy {
         """\(przeniesiona (z|na) lekcj[ię] ([0-9]+), (.+)\)""".toRegex()
     }
+    val VULCAN_WEB_PERMISSIONS by lazy {
+        """permissions: '([A-z0-9\/=+\-_]+?)'""".toRegex()
+    }
 
 
 
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/edudziennik/firstlogin/EdudziennikFirstLogin.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/edudziennik/firstlogin/EdudziennikFirstLogin.kt
index e47c7842..a93632b1 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/edudziennik/firstlogin/EdudziennikFirstLogin.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/edudziennik/firstlogin/EdudziennikFirstLogin.kt
@@ -59,7 +59,7 @@ class EdudziennikFirstLogin(val data: DataEdudziennik, val onSuccess: () -> Unit
                     profileList.add(profile)
                 }
 
-                EventBus.getDefault().post(FirstLoginFinishedEvent(profileList, data.loginStore))
+                EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
                 onSuccess()
             }
         }
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/idziennik/firstlogin/IdziennikFirstLogin.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/idziennik/firstlogin/IdziennikFirstLogin.kt
index 892c12c8..1a07c609 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/idziennik/firstlogin/IdziennikFirstLogin.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/idziennik/firstlogin/IdziennikFirstLogin.kt
@@ -89,7 +89,7 @@ class IdziennikFirstLogin(val data: DataIdziennik, val onSuccess: () -> Unit) {
                             profileList.add(profile)
                         }
 
-                EventBus.getDefault().post(FirstLoginFinishedEvent(profileList, data.loginStore))
+                EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
                 onSuccess()
             }
         }
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/librus/firstlogin/LibrusFirstLogin.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/librus/firstlogin/LibrusFirstLogin.kt
index cc62a061..68f44269 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/librus/firstlogin/LibrusFirstLogin.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/librus/firstlogin/LibrusFirstLogin.kt
@@ -33,7 +33,7 @@ class LibrusFirstLogin(val data: DataLibrus, val onSuccess: () -> Unit) {
                     val accounts = json.getJsonArray("accounts")
 
                     if (accounts == null || accounts.size() < 1) {
-                        EventBus.getDefault().post(FirstLoginFinishedEvent(listOf(), data.loginStore))
+                        EventBus.getDefault().postSticky(FirstLoginFinishedEvent(listOf(), data.loginStore))
                         onSuccess()
                         return@portalGet
                     }
@@ -81,7 +81,7 @@ class LibrusFirstLogin(val data: DataLibrus, val onSuccess: () -> Unit) {
                         profileList.add(profile)
                     }
 
-                    EventBus.getDefault().post(FirstLoginFinishedEvent(profileList, data.loginStore))
+                    EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
                     onSuccess()
                 }
             }
@@ -124,7 +124,7 @@ class LibrusFirstLogin(val data: DataLibrus, val onSuccess: () -> Unit) {
                     }
                     profileList.add(profile)
 
-                    EventBus.getDefault().post(FirstLoginFinishedEvent(profileList, data.loginStore))
+                    EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
                     onSuccess()
                 }
             }
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/mobidziennik/firstlogin/MobidziennikFirstLogin.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/mobidziennik/firstlogin/MobidziennikFirstLogin.kt
index 4857bd09..11b668f9 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/mobidziennik/firstlogin/MobidziennikFirstLogin.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/mobidziennik/firstlogin/MobidziennikFirstLogin.kt
@@ -85,7 +85,7 @@ class MobidziennikFirstLogin(val data: DataMobidziennik, val onSuccess: () -> Un
                     profileList.add(profile)
                 }
 
-                EventBus.getDefault().post(FirstLoginFinishedEvent(profileList, data.loginStore))
+                EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
                 onSuccess()
             }
         }
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/DataVulcan.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/DataVulcan.kt
index d326ebd4..fc11d73d 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/DataVulcan.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/DataVulcan.kt
@@ -17,9 +17,13 @@ import pl.szczodrzynski.edziennik.values
 
 class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app, profile, loginStore) {
 
+    fun isWebMainLoginValid() = currentSemesterEndDate-30 > currentTimeUnix()
+            && apiFingerprint[symbol].isNotNullNorEmpty()
+            && apiPrivateKey[symbol].isNotNullNorEmpty()
+            && symbol.isNotNullNorEmpty()
     fun isApiLoginValid() = currentSemesterEndDate-30 > currentTimeUnix()
-            && apiCertificateKey.isNotNullNorEmpty()
-            && apiCertificatePrivate.isNotNullNorEmpty()
+            && apiFingerprint[symbol].isNotNullNorEmpty()
+            && apiPrivateKey[symbol].isNotNullNorEmpty()
             && symbol.isNotNullNorEmpty()
 
     override fun satisfyLoginMethods() {
@@ -40,7 +44,7 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
                         id,
                         name,
                         Team.TYPE_CLASS,
-                        "$schoolName:$name",
+                        "$schoolCode:$name",
                         -1
                 )
                 teamList.put(id, teamObject)
@@ -48,7 +52,7 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
         }
     }
 
-    override fun generateUserCode() = "$schoolName:$studentId"
+    override fun generateUserCode() = "$schoolCode:$studentId"
 
     /**
      * A UONET+ client symbol.
@@ -59,8 +63,8 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
      */
     private var mSymbol: String? = null
     var symbol: String?
-        get() { mSymbol = mSymbol ?: loginStore.getLoginData("deviceSymbol", null); return mSymbol }
-        set(value) { loginStore.putLoginData("deviceSymbol", value); mSymbol = value }
+        get() { mSymbol = mSymbol ?: profile?.getStudentData("symbol", null); return mSymbol }
+        set(value) { profile?.putStudentData("symbol", value); mSymbol = value }
 
     /**
      * Group symbol/number of the student's school.
@@ -75,16 +79,26 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
         set(value) { profile?.putStudentData("schoolSymbol", value) ?: return; mSchoolSymbol = value }
 
     /**
-     * A school ID consisting of the [symbol] and [schoolSymbol].
+     * Short name of the school, used in some places.
+     *
+     * ListaUczniow/JednostkaSprawozdawczaSkrot, e.g. "SP Wilkow"
+     */
+    private var mSchoolShort: String? = null
+    var schoolShort: String?
+        get() { mSchoolShort = mSchoolShort ?: profile?.getStudentData("schoolShort", null); return mSchoolShort }
+        set(value) { profile?.putStudentData("schoolShort", value) ?: return; mSchoolShort = value }
+
+    /**
+     * A school code consisting of the [symbol] and [schoolSymbol].
      *
      * [symbol]_[schoolSymbol]
      *
      * e.g. "poznan_000088"
      */
-    private var mSchoolName: String? = null
-    var schoolName: String?
-        get() { mSchoolName = mSchoolName ?: profile?.getStudentData("schoolName", null); return mSchoolName }
-        set(value) { profile?.putStudentData("schoolName", value) ?: return; mSchoolName = value }
+    private var mSchoolCode: String? = null
+    var schoolCode: String?
+        get() { mSchoolCode = mSchoolCode ?: profile?.getStudentData("schoolName", null); return mSchoolCode }
+        set(value) { profile?.putStudentData("schoolName", value) ?: return; mSchoolCode = value }
 
     /**
      * ID of the student.
@@ -154,45 +168,34 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
      * After first login only 3 first characters are stored here.
      * This is later used to determine the API URL address.
      */
-    private var mApiToken: String? = null
-    var apiToken: String?
-        get() { mApiToken = mApiToken ?: loginStore.getLoginData("deviceToken", null); return mApiToken }
-        set(value) { loginStore.putLoginData("deviceToken", value); mApiToken = value }
+    private var mApiToken: Map<String?, String?>? = null
+    var apiToken: Map<String?, String?> = mapOf()
+        get() { mApiToken = mApiToken ?: loginStore.getLoginData("apiToken", null)?.let { app.gson.fromJson(it, field.toMutableMap()::class.java) }; return mApiToken ?: mapOf() }
+        set(value) { loginStore.putLoginData("apiToken", app.gson.toJson(value)); mApiToken = value }
 
     /**
      * A mobile API registration PIN.
      *
      * After first login, this is removed and/or set to null.
      */
-    private var mApiPin: String? = null
-    var apiPin: String?
-        get() { mApiPin = mApiPin ?: loginStore.getLoginData("devicePin", null); return mApiPin }
-        set(value) { loginStore.putLoginData("devicePin", value); mApiPin = value }
+    private var mApiPin: Map<String?, String?>? = null
+    var apiPin: Map<String?, String?> = mapOf()
+        get() { mApiPin = mApiPin ?: loginStore.getLoginData("apiPin", null)?.let { app.gson.fromJson(it, field.toMutableMap()::class.java) }; return mApiPin ?: mapOf() }
+        set(value) { loginStore.putLoginData("apiPin", app.gson.toJson(value)); mApiPin = value }
 
-    private var mApiCertificateKey: String? = null
-    var apiCertificateKey: String?
-        get() { mApiCertificateKey = mApiCertificateKey ?: loginStore.getLoginData("certificateKey", null); return mApiCertificateKey }
-        set(value) { loginStore.putLoginData("certificateKey", value); mApiCertificateKey = value }
+    private var mApiFingerprint: Map<String?, String?>? = null
+    var apiFingerprint: Map<String?, String?> = mapOf()
+        get() { mApiFingerprint = mApiFingerprint ?: loginStore.getLoginData("apiFingerprint", null)?.let { app.gson.fromJson(it, field.toMutableMap()::class.java) }; return mApiFingerprint ?: mapOf() }
+        set(value) { loginStore.putLoginData("apiFingerprint", app.gson.toJson(value)); mApiFingerprint = value }
 
-    /**
-     * This is not meant for normal usage.
-     *
-     * It provides a backward compatibility (<4.0) in order
-     * to migrate and use private keys instead of PFX.
-     */
-    private var mApiCertificatePfx: String? = null
-    var apiCertificatePfx: String?
-        get() { mApiCertificatePfx = mApiCertificatePfx ?: loginStore.getLoginData("certificatePfx", null); return mApiCertificatePfx }
-        set(value) { loginStore.putLoginData("certificatePfx", value); mApiCertificatePfx = value }
-
-    private var mApiCertificatePrivate: String? = null
-    var apiCertificatePrivate: String?
-        get() { mApiCertificatePrivate = mApiCertificatePrivate ?: loginStore.getLoginData("certificatePrivate", null); return mApiCertificatePrivate }
-        set(value) { loginStore.putLoginData("certificatePrivate", value); mApiCertificatePrivate = value }
+    private var mApiPrivateKey: Map<String?, String?>? = null
+    var apiPrivateKey: Map<String?, String?> = mapOf()
+        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 }
 
     val apiUrl: String?
         get() {
-            val url = when (apiToken?.substring(0, 3)) {
+            val url = when (apiToken[symbol]?.substring(0, 3)) {
                 "3S1" -> "https://lekcjaplus.vulcan.net.pl"
                 "TA1" -> "https://uonetplus-komunikacja.umt.tarnow.pl"
                 "OP1" -> "https://uonetplus-komunikacja.eszkola.opolskie.pl"
@@ -217,4 +220,95 @@ class DataVulcan(app: App, profile: Profile?, loginStore: LoginStore) : Data(app
         get() {
             return "$apiUrl$schoolSymbol/"
         }
+
+    /*   __          __  _       ______ _____   _                 _
+         \ \        / / | |     |  ____/ ____| | |               (_)
+          \ \  /\  / /__| |__   | |__ | (___   | |     ___   __ _ _ _ __
+           \ \/  \/ / _ \ '_ \  |  __| \___ \  | |    / _ \ / _` | | '_ \
+            \  /\  /  __/ |_) | | |    ____) | | |___| (_) | (_| | | | | |
+             \/  \/ \___|_.__/  |_|   |_____/  |______\___/ \__, |_|_| |_|
+                                                             __/ |
+                                                            |__*/
+    /**
+     * Federation Services login type.
+     * This might be one of: cufs, adfs, adfslight.
+     */
+    var webType: String?
+        get() { mWebType = mWebType ?: loginStore.getLoginData("webType", null); return mWebType }
+        set(value) { loginStore.putLoginData("webType", value); mWebType = value }
+    private var mWebType: String? = null
+
+    /**
+     * Web server providing the federation services login.
+     * If this is present, WEB_MAIN login is considered as available.
+     */
+    var webHost: String?
+        get() { mWebHost = mWebHost ?: loginStore.getLoginData("webHost", null); return mWebHost }
+        set(value) { loginStore.putLoginData("webHost", value); mWebHost = value }
+    private var mWebHost: String? = null
+
+    /**
+     * An ID used in ADFS & ADFSLight login types.
+     */
+    var webAdfsId: String?
+        get() { mWebAdfsId = mWebAdfsId ?: loginStore.getLoginData("webAdfsId", null); return mWebAdfsId }
+        set(value) { loginStore.putLoginData("webAdfsId", value); mWebAdfsId = value }
+    private var mWebAdfsId: String? = null
+
+    /**
+     * A domain override for ADFS Light.
+     */
+    var webAdfsDomain: String?
+        get() { mWebAdfsDomain = mWebAdfsDomain ?: loginStore.getLoginData("webAdfsDomain", null); return mWebAdfsDomain }
+        set(value) { loginStore.putLoginData("webAdfsDomain", value); mWebAdfsDomain = value }
+    private var mWebAdfsDomain: String? = null
+
+    var webIsHttpCufs: Boolean
+        get() { mWebIsHttpCufs = mWebIsHttpCufs ?: loginStore.getLoginData("webIsHttpCufs", false); return mWebIsHttpCufs ?: false }
+        set(value) { loginStore.putLoginData("webIsHttpCufs", value); mWebIsHttpCufs = value }
+    private var mWebIsHttpCufs: Boolean? = null
+
+    var webIsScopedAdfs: Boolean
+        get() { mWebIsScopedAdfs = mWebIsScopedAdfs ?: loginStore.getLoginData("webIsScopedAdfs", false); return mWebIsScopedAdfs ?: false }
+        set(value) { loginStore.putLoginData("webIsScopedAdfs", value); mWebIsScopedAdfs = value }
+    private var mWebIsScopedAdfs: Boolean? = null
+
+    var webEmail: String?
+        get() { mWebEmail = mWebEmail ?: loginStore.getLoginData("webEmail", null); return mWebEmail }
+        set(value) { loginStore.putLoginData("webEmail", value); mWebEmail = value }
+    private var mWebEmail: String? = null
+    var webUsername: String?
+        get() { mWebUsername = mWebUsername ?: loginStore.getLoginData("webUsername", null); return mWebUsername }
+        set(value) { loginStore.putLoginData("webUsername", value); mWebUsername = value }
+    private var mWebUsername: String? = null
+    var webPassword: String?
+        get() { mWebPassword = mWebPassword ?: loginStore.getLoginData("webPassword", null); return mWebPassword }
+        set(value) { loginStore.putLoginData("webPassword", value); mWebPassword = value }
+    private var mWebPassword: String? = null
+
+    /**
+     * Expiry time of a certificate POSTed to a LoginEndpoint of the specific symbol.
+     * If the time passes, the certificate needs to be POSTed again (if valid)
+     * or re-generated.
+     */
+    var webExpiryTime: Long
+        get() { mWebExpiryTime = mWebExpiryTime ?: profile?.getStudentData("webExpiryTime", 0L); return mWebExpiryTime ?: 0L }
+        set(value) { profile?.putStudentData("webExpiryTime", value); mWebExpiryTime = value }
+    private var mWebExpiryTime: Long? = null
+
+    /**
+     * EfebSsoAuthCookie retrieved after posting a certificate
+     */
+    var webAuthCookie: String?
+        get() { mWebAuthCookie = mWebAuthCookie ?: profile?.getStudentData("webAuthCookie", null); return mWebAuthCookie }
+        set(value) { profile?.putStudentData("webAuthCookie", value); mWebAuthCookie = value }
+    private var mWebAuthCookie: String? = null
+
+    /**
+     * Permissions needed to get JSONs from home page
+     */
+    var webPermissions: String?
+        get() { mWebPermissions = mWebPermissions ?: profile?.getStudentData("webPermissions", null); return mWebPermissions }
+        set(value) { profile?.putStudentData("webPermissions", value); mWebPermissions = value }
+    private var mWebPermissions: String? = null
 }
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/VulcanApi.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/VulcanApi.kt
index 172be3c0..c500e3e7 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/VulcanApi.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/VulcanApi.kt
@@ -106,11 +106,11 @@ open class VulcanApi(open val data: DataVulcan, open val lastSync: Long?) {
         Request.builder()
                 .url(url)
                 .userAgent(VULCAN_API_USER_AGENT)
-                .addHeader("RequestCertificateKey", data.apiCertificateKey)
+                .addHeader("RequestCertificateKey", data.apiFingerprint[data.symbol])
                 .addHeader("RequestSignatureValue",
                         try {
                             signContent(
-                                    data.apiCertificatePrivate ?: "",
+                                    data.apiPrivateKey[data.symbol] ?: "",
                                     finalPayload.toString()
                             )
                         } catch (e: Exception) {e.printStackTrace();""})
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/VulcanWebMain.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/VulcanWebMain.kt
new file mode 100644
index 00000000..a2c0247a
--- /dev/null
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/VulcanWebMain.kt
@@ -0,0 +1,277 @@
+/*
+ * Copyright (c) Kuba Szczodrzyński 2020-4-17.
+ */
+
+package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import im.wangchao.mhttp.Request
+import im.wangchao.mhttp.Response
+import im.wangchao.mhttp.callback.TextCallbackHandler
+import pl.droidsonroids.jspoon.Jspoon
+import pl.szczodrzynski.edziennik.data.api.*
+import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
+import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.CufsCertificate
+import pl.szczodrzynski.edziennik.data.api.models.ApiError
+import pl.szczodrzynski.edziennik.get
+import pl.szczodrzynski.edziennik.isNotNullNorBlank
+import pl.szczodrzynski.edziennik.utils.Utils
+import pl.szczodrzynski.edziennik.utils.models.Date
+import java.io.File
+import java.net.HttpURLConnection
+
+open class VulcanWebMain(open val data: DataVulcan, open val lastSync: Long?) {
+    companion object {
+        const val TAG = "VulcanWebMain"
+        const val WEB_MAIN = 0
+        const val WEB_OLD = 1
+        const val WEB_NEW = 2
+        const val WEB_MESSAGES = 3
+        const val STATE_SUCCESS = 0
+        const val STATE_NO_REGISTER = 1
+        const val STATE_LOGGED_OUT = 2
+    }
+
+    val profileId
+        get() = data.profile?.id ?: -1
+
+    val profile
+        get() = data.profile
+
+    private val certificateAdapter by lazy {
+        Jspoon.create().adapter(CufsCertificate::class.java)
+    }
+
+    fun saveCertificate(certificate: String) {
+        val file = File(data.app.filesDir, "cert_"+(data.webUsername ?: data.webEmail)+".xml")
+        file.writeText(certificate)
+    }
+
+    fun readCertificate(): String? {
+        val file = File(data.app.filesDir, "cert_"+(data.webUsername ?: data.webEmail)+".xml")
+        if (file.canRead())
+            return file.readText()
+        return null
+    }
+
+    fun parseCertificate(certificate: String): CufsCertificate {
+        val xml = certificate
+                .replace("<[a-z]+?:".toRegex(), "<")
+                .replace("</[a-z]+?:".toRegex(), "</")
+                .replace("\\sxmlns.*?=\".+?\"".toRegex(), "")
+
+        return certificateAdapter.fromHtml(xml)
+    }
+
+    fun postCertificate(certificate: String, symbol: String, onResult: (symbol: String, state: Int) -> Unit): Boolean {
+        val cufsCertificate = parseCertificate(certificate)
+
+        // check if the certificate is valid
+        if (Date.fromIso(cufsCertificate.expiryDate) < System.currentTimeMillis())
+            return false
+
+        val callback = object : TextCallbackHandler() {
+            override fun onSuccess(text: String?, response: Response?) {
+                if (response?.headers()?.get("Location")?.contains("LoginEndpoint.aspx") == true
+                        || response?.headers()?.get("Location")?.contains("?logout=true") == true) {
+                    onResult(symbol, STATE_LOGGED_OUT)
+                    return
+                }
+                if (text?.contains("LoginEndpoint.aspx?logout=true") == true) {
+                    onResult(symbol, STATE_NO_REGISTER)
+                    return
+                }
+                if (!validateCallback(text, response, jsonResponse = false)) {
+                    return
+                }
+                onResult(symbol, STATE_SUCCESS)
+            }
+
+            override fun onFailure(response: Response?, throwable: Throwable?) {
+                data.error(ApiError(TAG, ERROR_REQUEST_FAILURE)
+                        .withResponse(response)
+                        .withThrowable(throwable))
+            }
+        }
+
+        Request.builder()
+                .url("https://uonetplus.${data.webHost}/$symbol/LoginEndpoint.aspx")
+                .withClient(data.app.httpLazy)
+                .userAgent(SYSTEM_USER_AGENT)
+                .post()
+                .addParameter("wa", "wsignin1.0")
+                .addParameter("wctx", cufsCertificate.targetUrl)
+                .addParameter("wresult", certificate)
+                .allowErrorCode(HttpURLConnection.HTTP_BAD_REQUEST)
+                .allowErrorCode(HttpURLConnection.HTTP_FORBIDDEN)
+                .allowErrorCode(HttpURLConnection.HTTP_UNAUTHORIZED)
+                .allowErrorCode(HttpURLConnection.HTTP_UNAVAILABLE)
+                .allowErrorCode(429)
+                .callback(callback)
+                .build()
+                .enqueue()
+
+        return true
+    }
+
+    fun getStartPage(symbol: String = data.symbol ?: "default", postErrors: Boolean = true, onSuccess: (html: String, schoolSymbols: List<String>) -> Unit) {
+        val callback = object : TextCallbackHandler() {
+            override fun onSuccess(text: String?, response: Response?) {
+                if (!validateCallback(text, response, jsonResponse = false) || text == null) {
+                    return
+                }
+
+                if (postErrors) {
+                    when {
+                        text.contains("status absolwenta") -> ERROR_VULCAN_WEB_GRADUATE_ACCOUNT
+                        else -> null
+                    }?.let { errorCode ->
+                        data.error(ApiError(TAG, errorCode)
+                                .withResponse(response)
+                                .withApiResponse(text))
+                        return
+                    }
+                }
+
+                data.webPermissions = Regexes.VULCAN_WEB_PERMISSIONS.find(text)?.let { it[1] }
+
+                val schoolSymbols = mutableListOf<String>()
+                val clientUrl = "https://uonetplus-uczen.${data.webHost}/$symbol/"
+                var clientIndex = text.indexOf(clientUrl)
+                var count = 0
+                while (clientIndex != -1 && count < 100) {
+                    val startIndex = clientIndex + clientUrl.length
+                    val endIndex = text.indexOf('/', startIndex = startIndex)
+                    val schoolSymbol = text.substring(startIndex, endIndex)
+                    schoolSymbols += schoolSymbol
+                    clientIndex = text.indexOf(clientUrl, startIndex = endIndex)
+                    count++
+                }
+
+                onSuccess(text, schoolSymbols)
+            }
+
+            override fun onFailure(response: Response?, throwable: Throwable?) {
+                data.error(ApiError(TAG, ERROR_REQUEST_FAILURE)
+                        .withResponse(response)
+                        .withThrowable(throwable))
+            }
+        }
+
+        Request.builder()
+                .url("https://uonetplus.${data.webHost}/$symbol/Start.mvc/Index")
+                .userAgent(SYSTEM_USER_AGENT)
+                .get()
+                .allowErrorCode(HttpURLConnection.HTTP_BAD_REQUEST)
+                .allowErrorCode(HttpURLConnection.HTTP_FORBIDDEN)
+                .allowErrorCode(HttpURLConnection.HTTP_UNAUTHORIZED)
+                .allowErrorCode(HttpURLConnection.HTTP_UNAVAILABLE)
+                .allowErrorCode(429)
+                .callback(callback)
+                .build()
+                .enqueue()
+    }
+
+    private fun validateCallback(text: String?, response: Response?, jsonResponse: Boolean = true): Boolean {
+        if (text == null) {
+            data.error(ApiError(TAG, ERROR_RESPONSE_EMPTY)
+                    .withResponse(response))
+            return false
+        }
+
+        if (response?.code() !in 200..302 || (jsonResponse && !text.startsWith("{"))) {
+            when {
+                text.contains("The custom error module") -> ERROR_VULCAN_WEB_429
+                else -> ERROR_VULCAN_WEB_OTHER
+            }.let { errorCode ->
+                data.error(ApiError(TAG, errorCode)
+                        .withApiResponse(text)
+                        .withResponse(response))
+                return false
+            }
+        }
+
+        val cookies = data.app.cookieJar.getAll(data.webHost ?: "vulcan.net.pl")
+        val authCookie = cookies["EfebSsoAuthCookie"]
+        if ((authCookie == null || authCookie == "null") && data.webAuthCookie != null) {
+            data.app.cookieJar.set(data.webHost ?: "vulcan.net.pl", "EfebSsoAuthCookie", data.webAuthCookie)
+        }
+        else if (authCookie.isNotNullNorBlank() && authCookie != "null" && authCookie != data.webAuthCookie) {
+            data.webAuthCookie = authCookie
+        }
+        return true
+    }
+
+    fun webGetJson(
+            tag: String,
+            webType: Int,
+            endpoint: String,
+            method: Int = POST,
+            parameters: Map<String, Any> = emptyMap(),
+            onSuccess: (json: JsonObject, response: Response?) -> Unit
+    ) {
+        val url = "https://" + when (webType) {
+            WEB_MAIN -> "uonetplus"
+            WEB_OLD -> "uonetplus-opiekun"
+            WEB_NEW -> "uonetplus-uczen"
+            WEB_MESSAGES -> "uonetplus-uzytkownik"
+            else -> "uonetplus"
+        } + ".${data.webHost}/${data.symbol}/$endpoint"
+
+        Utils.d(tag, "Request: Vulcan/WebMain - $url")
+
+        val payload = JsonObject()
+        parameters.map { (name, value) ->
+            when (value) {
+                is JsonObject -> payload.add(name, value)
+                is JsonArray -> payload.add(name, value)
+                is String -> payload.addProperty(name, value)
+                is Int -> payload.addProperty(name, value)
+                is Long -> payload.addProperty(name, value)
+                is Float -> payload.addProperty(name, value)
+                is Char -> payload.addProperty(name, value)
+                is Boolean -> payload.addProperty(name, value)
+            }
+        }
+
+        val callback = object : TextCallbackHandler() {
+            override fun onSuccess(text: String?, response: Response?) {
+                if (!validateCallback(text, response))
+                    return
+
+                try {
+                    val json = JsonParser().parse(text).asJsonObject
+                    onSuccess(json, response)
+                } catch (e: Exception) {
+                    data.error(ApiError(tag, EXCEPTION_VULCAN_WEB_REQUEST)
+                            .withResponse(response)
+                            .withThrowable(e)
+                            .withApiResponse(text))
+                }
+            }
+
+            override fun onFailure(response: Response?, throwable: Throwable?) {
+                data.error(ApiError(tag, ERROR_REQUEST_FAILURE)
+                        .withResponse(response)
+                        .withThrowable(throwable))
+            }
+        }
+
+        Request.builder()
+                .url(url)
+                .userAgent(SYSTEM_USER_AGENT)
+                .apply {
+                    when (method) {
+                        GET -> get()
+                        POST -> post()
+                    }
+                }
+                .setJsonBody(payload)
+                .allowErrorCode(429)
+                .callback(callback)
+                .build()
+                .enqueue()
+    }
+}
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/api/VulcanApiTimetable.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/api/VulcanApiTimetable.kt
index d8899dbf..70830865 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/api/VulcanApiTimetable.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/data/api/VulcanApiTimetable.kt
@@ -80,7 +80,7 @@ class VulcanApiTimetable(override val data: DataVulcan,
                                 id,
                                 name,
                                 Team.TYPE_VIRTUAL,
-                                "${data.schoolName}:$name",
+                                "${data.schoolCode}:$name",
                                 teacherId ?: oldTeacherId ?: -1
                         )
                         data.teamList[id] = team
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/firstlogin/VulcanFirstLogin.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/firstlogin/VulcanFirstLogin.kt
index 291cb37b..3d7e3aea 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/firstlogin/VulcanFirstLogin.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/firstlogin/VulcanFirstLogin.kt
@@ -6,12 +6,14 @@ package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.firstlogin
 
 import org.greenrobot.eventbus.EventBus
 import pl.szczodrzynski.edziennik.*
-import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_VULCAN
-import pl.szczodrzynski.edziennik.data.api.VULCAN_API_ENDPOINT_STUDENT_LIST
+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.VulcanWebMain
 import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login.VulcanLoginApi
+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
 import pl.szczodrzynski.edziennik.data.db.entity.Profile
 import pl.szczodrzynski.edziennik.utils.models.Date
 
@@ -21,19 +23,92 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
     }
 
     private val api = VulcanApi(data, null)
+    private val web = VulcanWebMain(data, null)
     private val profileList = mutableListOf<Profile>()
+    private val loginStoreId = data.loginStore.id
+    private var firstProfileId = loginStoreId
+    private val tryingSymbols = mutableListOf<String>()
 
     init {
-        val loginStoreId = data.loginStore.id
-        val loginStoreType = LOGIN_TYPE_VULCAN
-        var firstProfileId = loginStoreId
+        if (data.loginStore.mode == LOGIN_MODE_VULCAN_WEB) {
+            VulcanLoginWebMain(data) {
+                val certificate = web.readCertificate() ?: run {
+                    data.error(ApiError(TAG, ERROR_VULCAN_WEB_NO_CERTIFICATE))
+                    return@VulcanLoginWebMain
+                }
 
+                if (data.symbol != null && data.symbol != "default") {
+                    tryingSymbols += data.symbol ?: "default"
+                }
+                else {
+                    val cufsCertificate = web.parseCertificate(certificate)
+                    tryingSymbols += cufsCertificate.userInstances
+                }
+
+                checkSymbol(certificate)
+            }
+        }
+        else {
+            registerDevice {
+                EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
+                onSuccess()
+            }
+        }
+    }
+
+    private fun checkSymbol(certificate: String) {
+        if (tryingSymbols.isEmpty()) {
+            EventBus.getDefault().postSticky(FirstLoginFinishedEvent(profileList, data.loginStore))
+            onSuccess()
+            return
+        }
+
+        val result = web.postCertificate(certificate, tryingSymbols.removeAt(0)) { symbol, state ->
+            when (state) {
+                VulcanWebMain.STATE_NO_REGISTER -> {
+                    checkSymbol(certificate)
+                }
+                VulcanWebMain.STATE_LOGGED_OUT -> data.error(ApiError(TAG, ERROR_VULCAN_WEB_LOGGED_OUT))
+                VulcanWebMain.STATE_SUCCESS -> {
+                    webRegisterDevice(symbol) {
+                        checkSymbol(certificate)
+                    }
+                }
+            }
+        }
+
+        // postCertificate returns false if the cert is not valid anymore
+        if (!result) {
+            data.error(ApiError(TAG, ERROR_VULCAN_WEB_CERTIFICATE_EXPIRED)
+                    .withApiResponse(certificate))
+        }
+    }
+
+    private fun webRegisterDevice(symbol: String, onSuccess: () -> Unit) {
+        web.getStartPage(symbol, postErrors = false) { _, schoolSymbols ->
+            data.symbol = symbol
+            val schoolSymbol = data.schoolSymbol ?: schoolSymbols.firstOrNull()
+            web.webGetJson(TAG, VulcanWebMain.WEB_NEW, "$schoolSymbol/$VULCAN_WEB_ENDPOINT_REGISTER_DEVICE") { result, _ ->
+                val json = result.getJsonObject("data")
+                data.symbol = symbol
+                data.apiToken = data.apiToken.toMutableMap().also {
+                    it[symbol] = json.getString("TokenKey")
+                }
+                data.apiPin = data.apiPin.toMutableMap().also {
+                    it[symbol] = json.getString("PIN")
+                }
+                registerDevice(onSuccess)
+            }
+        }
+    }
+
+    private fun registerDevice(onSuccess: () -> Unit) {
         VulcanLoginApi(data) {
-            api.apiGet(TAG, VULCAN_API_ENDPOINT_STUDENT_LIST, baseUrl = true) { json, response ->
+            api.apiGet(TAG, VULCAN_API_ENDPOINT_STUDENT_LIST, baseUrl = true) { json, _ ->
                 val students = json.getJsonArray("Data")
 
                 if (students == null || students.isEmpty()) {
-                    EventBus.getDefault().post(FirstLoginFinishedEvent(listOf(), data.loginStore))
+                    EventBus.getDefault().postSticky(FirstLoginFinishedEvent(listOf(), data.loginStore))
                     onSuccess()
                     return@apiGet
                 }
@@ -42,7 +117,8 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
                     val student = studentEl.asJsonObject
 
                     val schoolSymbol = student.getString("JednostkaSprawozdawczaSymbol") ?: return@forEach
-                    val schoolName = "${data.symbol}_$schoolSymbol"
+                    val schoolShort = student.getString("JednostkaSprawozdawczaSkrot") ?: return@forEach
+                    val schoolCode = "${data.symbol}_$schoolSymbol"
                     val studentId = student.getInt("Id") ?: return@forEach
                     val studentLoginId = student.getInt("UzytkownikLoginId") ?: return@forEach
                     val studentClassId = student.getInt("IdOddzial") ?: return@forEach
@@ -80,7 +156,7 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
                     val profile = Profile(
                             firstProfileId++,
                             loginStoreId,
-                            loginStoreType,
+                            LOGIN_TYPE_VULCAN,
                             studentNameLong,
                             userLogin,
                             studentNameLong,
@@ -88,13 +164,16 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
                             accountName
                     ).apply {
                         this.studentClassName = studentClassName
+                        studentData["symbol"] = data.symbol
+
                         studentData["studentId"] = studentId
                         studentData["studentLoginId"] = studentLoginId
                         studentData["studentClassId"] = studentClassId
                         studentData["studentSemesterId"] = studentSemesterId
                         studentData["studentSemesterNumber"] = studentSemesterNumber
                         studentData["schoolSymbol"] = schoolSymbol
-                        studentData["schoolName"] = schoolName
+                        studentData["schoolShort"] = schoolShort
+                        studentData["schoolName"] = schoolCode
                         studentData["currentSemesterEndDate"] = currentSemesterEndDate
                     }
                     dateSemester1Start?.let {
@@ -107,7 +186,6 @@ class VulcanFirstLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
                     profileList.add(profile)
                 }
 
-                EventBus.getDefault().post(FirstLoginFinishedEvent(profileList, data.loginStore))
                 onSuccess()
             }
         }
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/CufsCertificate.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/CufsCertificate.kt
new file mode 100644
index 00000000..5e2e299f
--- /dev/null
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/CufsCertificate.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) Kuba Szczodrzyński 2020-4-17.
+ */
+
+package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login
+
+import pl.droidsonroids.jspoon.annotation.Selector
+
+class CufsCertificate {
+        @Selector(value = "EndpointReference Address")
+        var targetUrl: String = ""
+
+        @Selector(value = "Lifetime Created")
+        var createdDate: String = ""
+
+        @Selector(value = "Lifetime Expires")
+        var expiryDate: String = ""
+
+        @Selector(value = "Attribute[AttributeName=UserInstance] AttributeValue")
+        var userInstances: List<String> = listOf()
+}
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLogin.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLogin.kt
index 4c11caf3..45c0153d 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLogin.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLogin.kt
@@ -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_WEB_MAIN
 import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
 import pl.szczodrzynski.edziennik.utils.Utils
 
@@ -45,6 +46,10 @@ class VulcanLogin(val data: DataVulcan, val onSuccess: () -> Unit) {
         }
         Utils.d(TAG, "Using login method $loginMethodId")
         when (loginMethodId) {
+            LOGIN_METHOD_VULCAN_WEB_MAIN -> {
+                data.startProgress(R.string.edziennik_progress_login_vulcan_web_main)
+                VulcanLoginWebMain(data) { onSuccess(loginMethodId) }
+            }
             LOGIN_METHOD_VULCAN_API -> {
                 data.startProgress(R.string.edziennik_progress_login_vulcan_api)
                 VulcanLoginApi(data) { onSuccess(loginMethodId) }
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLoginApi.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLoginApi.kt
index 4385b18a..cdf93c5d 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLoginApi.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLoginApi.kt
@@ -10,14 +10,11 @@ import im.wangchao.mhttp.Request
 import im.wangchao.mhttp.Response
 import im.wangchao.mhttp.callback.JsonCallbackHandler
 import io.github.wulkanowy.signer.android.getPrivateKeyFromCert
-import pl.szczodrzynski.edziennik.currentTimeUnix
+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.api.VulcanApiUpdateSemester
 import pl.szczodrzynski.edziennik.data.api.models.ApiError
-import pl.szczodrzynski.edziennik.getJsonObject
-import pl.szczodrzynski.edziennik.getString
-import pl.szczodrzynski.edziennik.isNotNullNorEmpty
 import pl.szczodrzynski.edziennik.utils.Utils.d
 import java.net.HttpURLConnection.HTTP_BAD_REQUEST
 import java.util.*
@@ -29,28 +26,14 @@ class VulcanLoginApi(val data: DataVulcan, val onSuccess: () -> Unit) {
     }
 
     init { run {
+        copyFromLoginStore()
+
         if (data.profile != null && data.isApiLoginValid()) {
             onSuccess()
         }
         else {
-            // < v4.0 - PFX to Private Key migration
-            if (data.apiCertificatePfx.isNotNullNorEmpty()) {
-                try {
-                    data.apiCertificatePrivate = getPrivateKeyFromCert(
-                            if (data.apiToken?.get(0) == 'F') VULCAN_API_PASSWORD_FAKELOG else VULCAN_API_PASSWORD,
-                            data.apiCertificatePfx ?: ""
-                    )
-                    data.loginStore.removeLoginData("certificatePfx")
-                } catch (e: Throwable) {
-                    e.printStackTrace()
-                } finally {
-                    onSuccess()
-                    return@run
-                }
-            }
-
-            if (data.apiCertificateKey.isNotNullNorEmpty()
-                    && data.apiCertificatePrivate.isNotNullNorEmpty()
+            if (data.apiFingerprint[data.symbol].isNotNullNorEmpty()
+                    && data.apiPrivateKey[data.symbol].isNotNullNorEmpty()
                     && data.symbol.isNotNullNorEmpty()) {
                 // (see data.isApiLoginValid())
                 // the semester end date is over
@@ -58,7 +41,7 @@ class VulcanLoginApi(val data: DataVulcan, val onSuccess: () -> Unit) {
                 return@run
             }
 
-            if (data.symbol.isNotNullNorEmpty() && data.apiToken.isNotNullNorEmpty() && data.apiPin.isNotNullNorEmpty()) {
+            if (data.symbol.isNotNullNorEmpty() && data.apiToken[data.symbol].isNotNullNorEmpty() && data.apiPin[data.symbol].isNotNullNorEmpty()) {
                 loginWithToken()
             }
             else {
@@ -67,6 +50,64 @@ class VulcanLoginApi(val data: DataVulcan, val onSuccess: () -> Unit) {
         }
     }}
 
+    private fun copyFromLoginStore() {
+        data.loginStore.data.apply {
+            // < v4.0 - PFX to Private Key migration
+            if (has("certificatePfx")) {
+                try {
+                    val privateKey = getPrivateKeyFromCert(
+                            if (data.apiToken[data.symbol]?.get(0) == 'F') VULCAN_API_PASSWORD_FAKELOG else VULCAN_API_PASSWORD,
+                            getString("certificatePfx") ?: ""
+                    )
+                    data.apiPrivateKey = mapOf(
+                            data.symbol to privateKey
+                    )
+                    remove("certificatePfx")
+                } catch (e: Throwable) {
+                    e.printStackTrace()
+                }
+            }
+
+            // 4.0 - new login form - copy user input to profile
+            if (has("symbol")) {
+                data.symbol = getString("symbol")
+                remove("symbol")
+            }
+
+            // 4.0 - before Vulcan Web impl - migrate from strings to Map of Symbol to String
+            if (has("deviceSymbol")) {
+                data.symbol = getString("deviceSymbol")
+                remove("deviceSymbol")
+            }
+            if (has("certificateKey")) {
+                data.apiFingerprint = data.apiFingerprint.toMutableMap().also {
+                    it[data.symbol] = getString("certificateKey")
+                }
+                remove("certificateKey")
+            }
+            if (has("certificatePrivate")) {
+                data.apiPrivateKey = data.apiPrivateKey.toMutableMap().also {
+                    it[data.symbol] = getString("certificatePrivate")
+                }
+                remove("certificatePrivate")
+            }
+
+            // map form inputs to the 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() {
         d(TAG, "Request: Vulcan/Login/Api - ${data.apiUrl}/$VULCAN_API_ENDPOINT_CERTIFICATE")
 
@@ -118,14 +159,22 @@ class VulcanLoginApi(val data: DataVulcan, val onSuccess: () -> Unit) {
                     return
                 }
 
-                data.apiCertificateKey = cert.getString("CertyfikatKlucz")
-                data.apiToken = data.apiToken?.substring(0, 3)
-                data.apiCertificatePrivate = getPrivateKeyFromCert(
-                        if (data.apiToken?.get(0) == 'F') VULCAN_API_PASSWORD_FAKELOG else VULCAN_API_PASSWORD,
+                val privateKey = getPrivateKeyFromCert(
+                        if (data.apiToken[data.symbol]?.get(0) == 'F') VULCAN_API_PASSWORD_FAKELOG else VULCAN_API_PASSWORD,
                         cert.getString("CertyfikatPfx") ?: ""
                 )
+
+                data.apiFingerprint = data.apiFingerprint.toMutableMap().also {
+                    it[data.symbol] = cert.getString("CertyfikatKlucz")
+                }
+                data.apiToken = data.apiToken.toMutableMap().also {
+                    it[data.symbol] = it[data.symbol]?.substring(0, 3)
+                }
+                data.apiPrivateKey = data.apiPrivateKey.toMutableMap().also {
+                    it[data.symbol] = privateKey
+                }
                 data.loginStore.removeLoginData("certificatePfx")
-                data.loginStore.removeLoginData("devicePin")
+                data.loginStore.removeLoginData("apiPin")
                 onSuccess()
             }
 
@@ -136,14 +185,26 @@ 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ć"
+
         Request.builder()
                 .url("${data.apiUrl}$VULCAN_API_ENDPOINT_CERTIFICATE")
                 .userAgent(VULCAN_API_USER_AGENT)
                 .addHeader("RequestMobileType", "RegisterDevice")
-                .addParameter("PIN", data.apiPin)
-                .addParameter("TokenKey", data.apiToken)
-                .addParameter("DeviceId", UUID.randomUUID().toString())
-                .addParameter("DeviceName", VULCAN_API_DEVICE_NAME)
+                .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("DeviceNameUser", "")
                 .addParameter("DeviceDescription", "")
                 .addParameter("DeviceSystemType", "Android")
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLoginWebMain.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLoginWebMain.kt
new file mode 100644
index 00000000..04a6f17e
--- /dev/null
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/api/edziennik/vulcan/login/VulcanLoginWebMain.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) Kuba Szczodrzyński 2020-4-16.
+ */
+
+package pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.login
+
+import pl.szczodrzynski.edziennik.App
+import pl.szczodrzynski.edziennik.data.api.*
+import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.DataVulcan
+import pl.szczodrzynski.edziennik.data.api.edziennik.vulcan.data.VulcanWebMain
+import pl.szczodrzynski.edziennik.data.api.models.ApiError
+import pl.szczodrzynski.edziennik.getString
+import pl.szczodrzynski.edziennik.isNotNullNorEmpty
+import pl.szczodrzynski.fslogin.FSLogin
+import pl.szczodrzynski.fslogin.realm.CufsRealm
+
+class VulcanLoginWebMain(val data: DataVulcan, val onSuccess: () -> Unit) {
+    companion object {
+        private const val TAG = "VulcanLoginWebMain"
+    }
+
+    private val web by lazy { VulcanWebMain(data, null) }
+
+    init { run {
+        copyFromLoginStore()
+
+        if (data.profile != null && data.isWebMainLoginValid()) {
+            onSuccess()
+        }
+        else {
+            if (data.symbol.isNotNullNorEmpty()
+                    && data.webType.isNotNullNorEmpty()
+                    && data.webHost.isNotNullNorEmpty()
+                    && (data.webEmail.isNotNullNorEmpty() || data.webUsername.isNotNullNorEmpty())
+                    && data.webPassword.isNotNullNorEmpty()) {
+                try {
+                    val success = loginWithCredentials()
+                    if (!success)
+                        data.error(ApiError(TAG, ERROR_VULCAN_WEB_DATA_MISSING))
+                } catch (e: Exception) {
+                    data.error(ApiError(TAG, EXCEPTION_VULCAN_WEB_LOGIN)
+                            .withThrowable(e))
+                }
+            }
+            else {
+                data.error(ApiError(TAG, ERROR_LOGIN_DATA_MISSING))
+            }
+        }
+    }}
+
+    private fun copyFromLoginStore() {
+        data.loginStore.data.apply {
+            // 4.0 - new login form - copy user input to profile
+            if (has("symbol")) {
+                data.symbol = getString("symbol")
+                remove("symbol")
+            }
+        }
+    }
+
+    private fun loginWithCredentials(): Boolean {
+        val realm = when (data.webType) {
+            "cufs" -> CufsRealm(
+                    host = data.webHost ?: return false,
+                    symbol = data.symbol ?: "default",
+                    httpCufs = data.webIsHttpCufs
+            )
+            "adfs" -> CufsRealm(
+                    host = data.webHost ?: return false,
+                    symbol = data.symbol ?: "default",
+                    httpCufs = data.webIsHttpCufs
+            ).toAdfsRealm(id = data.webAdfsId ?: return false)
+            "adfslight" -> CufsRealm(
+                    host = data.webHost ?: return false,
+                    symbol = data.symbol ?: "default",
+                    httpCufs = data.webIsHttpCufs
+            ).toAdfsLightRealm(
+                    id = data.webAdfsId ?: return false,
+                    domain = data.webAdfsDomain ?: "adfslight",
+                    isScoped = data.webIsScopedAdfs
+            )
+            else -> return false
+        }
+
+        val fsLogin = FSLogin(data.app.http, debug = App.debugMode)
+        fsLogin.performLogin(
+                realm = realm,
+                username = data.webUsername ?: data.webEmail ?: return false,
+                password = data.webPassword ?: return false,
+                onSuccess = { certificate ->
+                    web.saveCertificate(certificate.wresult)
+
+                    // auto-post certificate when not first login
+                    if (data.profile != null && data.symbol != null && data.symbol != "default") {
+                        val result = web.postCertificate(certificate.wresult, data.symbol ?: "default") { _, state ->
+                            when (state) {
+                                VulcanWebMain.STATE_SUCCESS -> {
+                                    web.getStartPage { _, _ -> onSuccess() }
+                                }
+                                VulcanWebMain.STATE_NO_REGISTER -> data.error(ApiError(TAG, ERROR_VULCAN_WEB_NO_REGISTER))
+                                VulcanWebMain.STATE_LOGGED_OUT -> data.error(ApiError(TAG, ERROR_VULCAN_WEB_LOGGED_OUT))
+                            }
+                        }
+                        // postCertificate returns false if the cert is not valid anymore
+                        if (!result) {
+                            data.error(ApiError(TAG, ERROR_VULCAN_WEB_CERTIFICATE_EXPIRED)
+                                    .withApiResponse(certificate.wresult))
+                        }
+                    }
+                    else {
+                        // first login - succeed immediately
+                        onSuccess()
+                    }
+                },
+                onFailure = { errorText ->
+                    // TODO
+                    data.error(ApiError(TAG, 0).withThrowable(RuntimeException(errorText)))
+                }
+        )
+
+        return true
+    }
+}
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginInfo.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginInfo.kt
index 29624d40..04e9bf16 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginInfo.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginInfo.kt
@@ -152,7 +152,7 @@ object LoginInfo {
                                                     caseMode = Credential.CaseMode.UPPER_CASE
                                             ),
                                             Credential(
-                                                    keyName = "deviceSymbol",
+                                                    keyName = "symbol",
                                                     name = R.string.login_hint_symbol,
                                                     icon = CommunityMaterial.Icon2.cmd_school,
                                                     emptyText = R.string.login_error_no_symbol,
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginProgressFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginProgressFragment.kt
index ab76327e..930ee367 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginProgressFragment.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginProgressFragment.kt
@@ -91,7 +91,7 @@ class LoginProgressFragment : Fragment(), CoroutineScope {
         }
     }
 
-    @Subscribe(threadMode = ThreadMode.MAIN)
+    @Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
     fun onFirstLoginFinishedEvent(event: FirstLoginFinishedEvent) {
         if (event.profileList.isEmpty()) {
             MaterialAlertDialogBuilder(activity)
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginSyncFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginSyncFragment.kt
index c0aab663..04029d36 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginSyncFragment.kt
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/login/LoginSyncFragment.kt
@@ -85,7 +85,7 @@ class LoginSyncFragment : Fragment(), CoroutineScope {
         ).concat(" ")
     }
 
-    @Subscribe(threadMode = ThreadMode.MAIN)
+    @Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
     fun onSyncFinishedEvent(event: ApiTaskAllFinishedEvent) {
         nav.navigate(R.id.loginFinishFragment, finishArguments, activity.navOptions)
     }
diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/models/Date.java b/app/src/main/java/pl/szczodrzynski/edziennik/utils/models/Date.java
index c1d32538..88323bc1 100644
--- a/app/src/main/java/pl/szczodrzynski/edziennik/utils/models/Date.java
+++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/models/Date.java
@@ -8,6 +8,7 @@ import androidx.annotation.Nullable;
 import java.text.DateFormat;
 import java.util.Calendar;
 import java.util.Locale;
+import java.util.TimeZone;
 
 import pl.szczodrzynski.edziennik.ExtensionsKt;
 import pl.szczodrzynski.edziennik.R;
@@ -108,7 +109,11 @@ public class Date implements Comparable<Date> {
 
     public static long fromIso(String dateTime) {
         try {
-            return Date.fromY_m_d(dateTime).combineWith(new Time(Integer.parseInt(dateTime.substring(11, 13)), Integer.parseInt(dateTime.substring(14, 16)), Integer.parseInt(dateTime.substring(17, 19))));
+            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"));
+            return c.getTimeInMillis();
         }
         catch (Exception e) {
             return System.currentTimeMillis();
diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml
index 63a4607b..e6d90882 100644
--- a/app/src/main/res/values-en/strings.xml
+++ b/app/src/main/res/values-en/strings.xml
@@ -1230,4 +1230,7 @@
     <string name="yesterday">yesterday</string>
     <string name="you_are_offline_text">You\'re offline. Try enabling Wi-Fi or mobile data.</string>
     <string name="you_are_offline_title">Internet connection</string>
+    <string name="permissions_attachment">In order to download the file, you have to grant file storage permission for the application.\n\nClick OK to grant the permission.</string>
+    <string name="permissions_denied">You denied the required permissions for the application.\n\nIn order to grant the permission, open the Permissions screen for Szkolny.eu in phone settings.\n\nClick OK to open app settings now.</string>
+    <string name="permissions_required">Required permissions</string>
 </resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5eb7fd82..8812f92c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1288,19 +1288,19 @@
     <string name="you_are_offline_title">Połączenie sieciowe</string>
     <string name="login_chooser_title">Jaki masz e-dziennik w szkole?</string>
     <string name="login_chooser_subtitle">Wybierz z jakiego e-dziennika korzysta Twoja szkoła. Jeśli masz kilka kont w różnych dziennikach, będziesz mógł je dodać później.</string>
-    <string name="login_register_librus">Librus/Synergia</string>
+    <string name="login_register_librus" translatable="false">Librus/Synergia</string>
     <string name="login_mode_librus_email">Zaloguj używając e-maila</string>
     <string name="login_mode_librus_email_hint">Musisz posiadać konto Librus Rodzina</string>
     <string name="login_mode_librus_synergia">Zaloguj używając loginu i hasła</string>
     <string name="login_mode_librus_synergia_hint">Użyj loginu w postaci \"9874123u\"</string>
     <string name="login_mode_librus_jst">Logowanie przez platformę VULCAN</string>
     <string name="login_mode_librus_jst_hint">Oświata w Radomiu oraz Innowacyjny Tarnobrzeg</string>
-    <string name="login_type_vulcan">Vulcan UONET+</string>
+    <string name="login_type_vulcan" translatable="false">Vulcan UONET+</string>
     <string name="login_mode_vulcan_api">Użyj tokenu, symbolu i kodu PIN</string>
     <string name="login_mode_vulcan_api_hint">Zarejestruj urządzenie na stronie dziennika Vulcan</string>
     <string name="login_mode_vulcan_web">Użyj e-maila/nazwy użytkownika i hasła</string>
     <string name="login_mode_vulcan_web_hint">Zaloguj danymi, które podajesz na stronie e-dziennika VULCAN</string>
-    <string name="login_type_mobidziennik">MobiDziennik</string>
+    <string name="login_type_mobidziennik" translatable="false">MobiDziennik</string>
     <string name="login_mode_mobidziennik_web">Zaloguj nazwą serwera, loginem i hasłem</string>
     <string name="login_mode_mobidziennik_web_hint">Podaj dane, których używasz na stronie e-dziennika</string>
     <string name="login_platform_list_title">W jaki sposób się logujesz do dziennika?</string>
@@ -1316,4 +1316,5 @@
     <string name="login_mode_mobidziennik_web_guide">Podaj dane, których używasz do logowania na stronie MobiDziennika. Jako adres serwera możesz wpisać adres strony internetowej, na której masz MobiDziennik.</string>
     <string name="settings_add_student_text">Dodaj nowego ucznia</string>
     <string name="settings_add_student_subtext">Zaloguj konto ucznia/rodzica w aplikacji</string>
+    <string name="edziennik_progress_login_vulcan_web_main">Logowanie do dziennika Vulcan...</string>
 </resources>