1
0

Compare commits

...

14 Commits
2.5.3 ... 2.5.6

21 changed files with 85 additions and 44 deletions

View File

@ -27,8 +27,8 @@ android {
testApplicationId "io.github.tests.wulkanowy" testApplicationId "io.github.tests.wulkanowy"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 34 targetSdkVersion 34
versionCode 152 versionCode 155
versionName "2.5.3" versionName "2.5.6"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resValue "string", "app_name", "Wulkanowy" resValue "string", "app_name", "Wulkanowy"
@ -164,7 +164,7 @@ play {
defaultToAppBundles = false defaultToAppBundles = false
track = 'production' track = 'production'
releaseStatus = ReleaseStatus.IN_PROGRESS releaseStatus = ReleaseStatus.IN_PROGRESS
userFraction = 0.20d userFraction = 0.99d
updatePriority = 3 updatePriority = 3
enabled.set(false) enabled.set(false)
} }
@ -195,7 +195,7 @@ ext {
} }
dependencies { dependencies {
implementation 'io.github.wulkanowy:sdk:2.5.3' implementation 'io.github.wulkanowy:sdk:2.5.6'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'

View File

@ -23,6 +23,7 @@ class WulkanowySdkFactory @Inject constructor(
) { ) {
private val eduOneMutex = Mutex() private val eduOneMutex = Mutex()
private val migrationFailedStudentIds = mutableSetOf<Long>()
private val sdk = Sdk().apply { private val sdk = Sdk().apply {
androidVersion = android.os.Build.VERSION.RELEASE androidVersion = android.os.Build.VERSION.RELEASE
@ -78,14 +79,24 @@ class WulkanowySdkFactory @Inject constructor(
private suspend fun checkEduOneAndMigrateIfNecessary(student: Student): Boolean { private suspend fun checkEduOneAndMigrateIfNecessary(student: Student): Boolean {
if (student.isEduOne != null) return student.isEduOne if (student.isEduOne != null) return student.isEduOne
if (student.id in migrationFailedStudentIds) {
Timber.i("Migration eduOne: skipping because of previous failure")
return false
}
eduOneMutex.withLock { eduOneMutex.withLock {
if (student.id in migrationFailedStudentIds) {
Timber.i("Migration eduOne: skipping because of previous failure")
return false
}
val studentFromDatabase = studentDb.loadById(student.id) val studentFromDatabase = studentDb.loadById(student.id)
if (studentFromDatabase?.isEduOne != null) { if (studentFromDatabase?.isEduOne != null) {
Timber.d("Migration eduOne: already done") Timber.i("Migration eduOne: already done")
return studentFromDatabase.isEduOne return studentFromDatabase.isEduOne
} }
Timber.d("Migration eduOne: flag missing. Running migration...") Timber.i("Migration eduOne: flag missing. Running migration...")
val initializedSdk = buildSdk( val initializedSdk = buildSdk(
student = student, student = student,
semester = null, semester = null,
@ -96,11 +107,12 @@ class WulkanowySdkFactory @Inject constructor(
.getOrNull() .getOrNull()
if (newCurrentStudent == null) { if (newCurrentStudent == null) {
Timber.d("Migration eduOne: failed, so skipping") Timber.i("Migration eduOne: failed, so skipping")
migrationFailedStudentIds.add(student.id)
return false return false
} }
Timber.d("Migration eduOne: success. New isEduOne flag: ${newCurrentStudent.isEduOne}") Timber.i("Migration eduOne: success. New isEduOne flag: ${newCurrentStudent.isEduOne}")
val studentIsEduOne = StudentIsEduOne( val studentIsEduOne = StudentIsEduOne(
id = student.id, id = student.id,

View File

@ -8,6 +8,6 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface MobileDeviceDao : BaseDao<MobileDevice> { interface MobileDeviceDao : BaseDao<MobileDevice> {
@Query("SELECT * FROM MobileDevices WHERE user_login_id = :userLoginId ORDER BY date DESC") @Query("SELECT * FROM MobileDevices WHERE user_login_id = :studentId ORDER BY date DESC")
fun loadAll(userLoginId: Int): Flow<List<MobileDevice>> fun loadAll(studentId: Int): Flow<List<MobileDevice>>
} }

View File

@ -10,6 +10,6 @@ import javax.inject.Singleton
@Singleton @Singleton
interface SchoolAnnouncementDao : BaseDao<SchoolAnnouncement> { interface SchoolAnnouncementDao : BaseDao<SchoolAnnouncement> {
@Query("SELECT * FROM SchoolAnnouncements WHERE user_login_id = :userLoginId ORDER BY date DESC") @Query("SELECT * FROM SchoolAnnouncements WHERE user_login_id = :studentId ORDER BY date DESC")
fun loadAll(userLoginId: Int): Flow<List<SchoolAnnouncement>> fun loadAll(studentId: Int): Flow<List<SchoolAnnouncement>>
} }

View File

@ -14,6 +14,6 @@ interface SemesterDao : BaseDao<Semester> {
@Insert(onConflict = OnConflictStrategy.IGNORE) @Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertSemesters(items: List<Semester>): List<Long> suspend fun insertSemesters(items: List<Semester>): List<Long>
@Query("SELECT * FROM Semesters WHERE student_id = :studentId AND class_id = :classId") @Query("SELECT * FROM Semesters WHERE (student_id = :studentId AND class_id = :classId) OR (student_id = :studentId AND class_id = 0)")
suspend fun loadAll(studentId: Int, classId: Int): List<Semester> suspend fun loadAll(studentId: Int, classId: Int): List<Semester>
} }

View File

@ -47,11 +47,11 @@ abstract class StudentDao {
abstract suspend fun loadAll(): List<Student> abstract suspend fun loadAll(): List<Student>
@Transaction @Transaction
@Query("SELECT * FROM Students JOIN Semesters ON Students.student_id = Semesters.student_id AND Students.class_id = Semesters.class_id") @Query("SELECT * FROM Students JOIN Semesters ON (Students.student_id = Semesters.student_id AND Students.class_id = Semesters.class_id) OR (Students.student_id = Semesters.student_id AND Semesters.class_id = 0)")
abstract suspend fun loadStudentsWithSemesters(): Map<Student, List<Semester>> abstract suspend fun loadStudentsWithSemesters(): Map<Student, List<Semester>>
@Transaction @Transaction
@Query("SELECT * FROM Students JOIN Semesters ON Students.student_id = Semesters.student_id AND Students.class_id = Semesters.class_id WHERE Students.id = :id") @Query("SELECT * FROM Students JOIN Semesters ON (Students.student_id = Semesters.student_id AND Students.class_id = Semesters.class_id) OR (Students.student_id = Semesters.student_id AND Semesters.class_id = 0) WHERE Students.id = :id")
abstract suspend fun loadStudentWithSemestersById(id: Long): Map<Student, List<Semester>> abstract suspend fun loadStudentWithSemestersById(id: Long): Map<Student, List<Semester>>
@Query("UPDATE Students SET is_current = 1 WHERE id = :id") @Query("UPDATE Students SET is_current = 1 WHERE id = :id")

View File

@ -9,8 +9,8 @@ import java.time.Instant
@Entity(tableName = "MobileDevices") @Entity(tableName = "MobileDevices")
data class MobileDevice( data class MobileDevice(
@ColumnInfo(name = "user_login_id") @ColumnInfo(name = "user_login_id") // todo: change column name
val userLoginId: Int, val studentId: Int,
@ColumnInfo(name = "device_id") @ColumnInfo(name = "device_id")
val deviceId: Int, val deviceId: Int,

View File

@ -9,8 +9,8 @@ import java.time.LocalDate
@Entity(tableName = "SchoolAnnouncements") @Entity(tableName = "SchoolAnnouncements")
data class SchoolAnnouncement( data class SchoolAnnouncement(
@ColumnInfo(name = "user_login_id") @ColumnInfo(name = "user_login_id") // todo: change column name
val userLoginId: Int, val studentId: Int,
val date: LocalDate, val date: LocalDate,

View File

@ -49,6 +49,7 @@ data class Student(
@ColumnInfo(name = "student_id") @ColumnInfo(name = "student_id")
val studentId: Int, val studentId: Int,
@Deprecated("not available in VULCAN anymore")
@ColumnInfo(name = "user_login_id") @ColumnInfo(name = "user_login_id")
val userLoginId: Int, val userLoginId: Int,

View File

@ -8,7 +8,7 @@ import io.github.wulkanowy.sdk.pojo.LastAnnouncement as SdkLastAnnouncement
@JvmName("mapDirectorInformationToEntities") @JvmName("mapDirectorInformationToEntities")
fun List<SdkDirectorInformation>.mapToEntities(student: Student) = map { fun List<SdkDirectorInformation>.mapToEntities(student: Student) = map {
SchoolAnnouncement( SchoolAnnouncement(
userLoginId = student.userLoginId, studentId = student.studentId,
date = it.date, date = it.date,
subject = it.subject, subject = it.subject,
content = it.content, content = it.content,
@ -19,7 +19,7 @@ fun List<SdkDirectorInformation>.mapToEntities(student: Student) = map {
@JvmName("mapLastAnnouncementsToEntities") @JvmName("mapLastAnnouncementsToEntities")
fun List<SdkLastAnnouncement>.mapToEntities(student: Student) = map { fun List<SdkLastAnnouncement>.mapToEntities(student: Student) = map {
SchoolAnnouncement( SchoolAnnouncement(
userLoginId = student.userLoginId, studentId = student.studentId,
date = it.date, date = it.date,
subject = it.subject, subject = it.subject,
content = it.content, content = it.content,

View File

@ -8,7 +8,7 @@ import io.github.wulkanowy.sdk.pojo.Token as SdkToken
fun List<SdkDevice>.mapToEntities(student: Student) = map { fun List<SdkDevice>.mapToEntities(student: Student) = map {
MobileDevice( MobileDevice(
userLoginId = student.userLoginId, studentId = student.studentId,
date = it.createDate.toInstant(), date = it.createDate.toInstant(),
deviceId = it.id, deviceId = it.id,
name = it.name name = it.name

View File

@ -38,7 +38,7 @@ class MobileDeviceRepository @Inject constructor(
val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, student)) val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, student))
it.isEmpty() || forceRefresh || isExpired it.isEmpty() || forceRefresh || isExpired
}, },
query = { mobileDb.loadAll(student.userLoginId) }, query = { mobileDb.loadAll(student.studentId) },
fetch = { fetch = {
wulkanowySdkFactory.create(student, semester) wulkanowySdkFactory.create(student, semester)
.getRegisteredDevices() .getRegisteredDevices()

View File

@ -37,7 +37,7 @@ class SchoolAnnouncementRepository @Inject constructor(
it.isEmpty() || forceRefresh || isExpired it.isEmpty() || forceRefresh || isExpired
}, },
query = { query = {
schoolAnnouncementDb.loadAll(student.userLoginId) schoolAnnouncementDb.loadAll(student.studentId)
}, },
fetch = { fetch = {
val sdk = wulkanowySdkFactory.create(student) val sdk = wulkanowySdkFactory.create(student)
@ -57,7 +57,7 @@ class SchoolAnnouncementRepository @Inject constructor(
) )
fun getSchoolAnnouncementFromDatabase(student: Student): Flow<List<SchoolAnnouncement>> { fun getSchoolAnnouncementFromDatabase(student: Student): Flow<List<SchoolAnnouncement>> {
return schoolAnnouncementDb.loadAll(student.userLoginId) return schoolAnnouncementDb.loadAll(student.studentId)
} }
suspend fun updateSchoolAnnouncement(schoolAnnouncement: List<SchoolAnnouncement>) = suspend fun updateSchoolAnnouncement(schoolAnnouncement: List<SchoolAnnouncement>) =

View File

@ -64,7 +64,10 @@ class SemesterRepository @Inject constructor(
.getSemesters() .getSemesters()
.mapToEntities(student.studentId) .mapToEntities(student.studentId)
if (new.isEmpty()) return Timber.i("Empty semester list!") if (new.isEmpty()) {
Timber.i("Empty semester list from SDK!")
return
}
val old = semesterDb.loadAll(student.studentId, student.classId) val old = semesterDb.loadAll(student.studentId, student.classId)
semesterDb.removeOldAndSaveNew( semesterDb.removeOldAndSaveNew(

View File

@ -42,6 +42,7 @@ class StudentRepository @Inject constructor(
): RegisterUser = wulkanowySdkFactory.create() ): RegisterUser = wulkanowySdkFactory.create()
.getStudentsFromHebe(token, pin, symbol, "") .getStudentsFromHebe(token, pin, symbol, "")
.mapToPojo(null) .mapToPojo(null)
.also { it.logErrors() }
suspend fun getUserSubjectsFromScrapper( suspend fun getUserSubjectsFromScrapper(
email: String, email: String,
@ -52,6 +53,7 @@ class StudentRepository @Inject constructor(
): RegisterUser = wulkanowySdkFactory.create() ): RegisterUser = wulkanowySdkFactory.create()
.getUserSubjectsFromScrapper(email, password, scrapperBaseUrl, domainSuffix, symbol) .getUserSubjectsFromScrapper(email, password, scrapperBaseUrl, domainSuffix, symbol)
.mapToPojo(password) .mapToPojo(password)
.also { it.logErrors() }
suspend fun getStudentsHybrid( suspend fun getStudentsHybrid(
email: String, email: String,
@ -61,6 +63,7 @@ class StudentRepository @Inject constructor(
): RegisterUser = wulkanowySdkFactory.create() ): RegisterUser = wulkanowySdkFactory.create()
.getStudentsHybrid(email, password, scrapperBaseUrl, "", symbol) .getStudentsHybrid(email, password, scrapperBaseUrl, "", symbol)
.mapToPojo(password) .mapToPojo(password)
.also { it.logErrors() }
suspend fun getSavedStudents(decryptPass: Boolean = true): List<StudentWithSemesters> { suspend fun getSavedStudents(decryptPass: Boolean = true): List<StudentWithSemesters> {
return studentDb.loadStudentsWithSemesters().map { (student, semesters) -> return studentDb.loadStudentsWithSemesters().map { (student, semesters) ->
@ -195,10 +198,10 @@ class StudentRepository @Inject constructor(
.authorizePermission(pesel) .authorizePermission(pesel)
suspend fun refreshStudentAfterAuthorize(student: Student, semester: Semester) { suspend fun refreshStudentAfterAuthorize(student: Student, semester: Semester) {
val newCurrentApiStudent = wulkanowySdkFactory val wulkanowySdk = wulkanowySdkFactory.create(student, semester)
.create(student, semester) val newCurrentApiStudent = runCatching { wulkanowySdk.getCurrentStudent() }
.getCurrentStudent() .onFailure { Timber.e(it, "Can't find student with id ${student.studentId}") }
?: return Timber.d("Can't find student with id ${student.studentId}") .getOrNull() ?: return
val studentName = StudentName( val studentName = StudentName(
studentName = "${newCurrentApiStudent.studentName} ${newCurrentApiStudent.studentSurname}" studentName = "${newCurrentApiStudent.studentName} ${newCurrentApiStudent.studentSurname}"
@ -221,6 +224,18 @@ class StudentRepository @Inject constructor(
appDatabase.clearAllTables() appDatabase.clearAllTables()
} }
} }
private fun RegisterUser.logErrors() {
val symbolsErrors = symbols.filter { it.error != null }
.map { it.error }
val unitsErrors = symbols.flatMap { it.schools }
.filter { it.error != null }
.map { it.error }
(symbolsErrors + unitsErrors).forEach { error ->
Timber.e(error, "Error occurred while fetching students")
}
}
} }
class NoAuthorizationException : Exception() class NoAuthorizationException : Exception()

View File

@ -19,6 +19,6 @@ val debugSchoolAnnouncementItems = listOf(
private fun generateAnnouncement(subject: String, content: String) = SchoolAnnouncement( private fun generateAnnouncement(subject: String, content: String) = SchoolAnnouncement(
subject = subject, subject = subject,
content = content, content = content,
userLoginId = 0, studentId = 0,
date = LocalDate.now() date = LocalDate.now()
) )

View File

@ -19,19 +19,23 @@ class LoginStudentSelectAdapter @Inject constructor() :
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
return when (LoginStudentSelectItemType.values()[viewType]) { return when (LoginStudentSelectItemType.entries[viewType]) {
LoginStudentSelectItemType.EMPTY_SYMBOLS_HEADER -> EmptySymbolsHeaderViewHolder( LoginStudentSelectItemType.EMPTY_SYMBOLS_HEADER -> EmptySymbolsHeaderViewHolder(
ItemLoginStudentSelectEmptySymbolHeaderBinding.inflate(inflater, parent, false), ItemLoginStudentSelectEmptySymbolHeaderBinding.inflate(inflater, parent, false),
) )
LoginStudentSelectItemType.SYMBOL_HEADER -> SymbolsHeaderViewHolder( LoginStudentSelectItemType.SYMBOL_HEADER -> SymbolsHeaderViewHolder(
ItemLoginStudentSelectHeaderSymbolBinding.inflate(inflater, parent, false) ItemLoginStudentSelectHeaderSymbolBinding.inflate(inflater, parent, false)
) )
LoginStudentSelectItemType.SCHOOL_HEADER -> SchoolHeaderViewHolder( LoginStudentSelectItemType.SCHOOL_HEADER -> SchoolHeaderViewHolder(
ItemLoginStudentSelectHeaderSchoolBinding.inflate(inflater, parent, false) ItemLoginStudentSelectHeaderSchoolBinding.inflate(inflater, parent, false)
) )
LoginStudentSelectItemType.STUDENT -> StudentViewHolder( LoginStudentSelectItemType.STUDENT -> StudentViewHolder(
ItemLoginStudentSelectStudentBinding.inflate(inflater, parent, false) ItemLoginStudentSelectStudentBinding.inflate(inflater, parent, false)
) )
LoginStudentSelectItemType.HELP -> HelpViewHolder( LoginStudentSelectItemType.HELP -> HelpViewHolder(
ItemLoginStudentSelectHelpBinding.inflate(inflater, parent, false) ItemLoginStudentSelectHelpBinding.inflate(inflater, parent, false)
) )
@ -98,9 +102,11 @@ class LoginStudentSelectAdapter @Inject constructor() :
with(binding) { with(binding) {
loginStudentSelectHeaderSchoolName.text = buildString { loginStudentSelectHeaderSchoolName.text = buildString {
append(item.unit.schoolName.trim()) append(item.unit.schoolName.trim())
append(" (") if (item.unit.schoolShortName.isNotBlank()) {
append(item.unit.schoolShortName) append(" (")
append(")") append(item.unit.schoolShortName)
append(")")
}
} }
loginStudentSelectHeaderSchoolDetails.isVisible = item.unit.students.isEmpty() loginStudentSelectHeaderSchoolDetails.isVisible = item.unit.students.isEmpty()
loginStudentSelectHeaderSchoolError.text = item.unit.error?.message loginStudentSelectHeaderSchoolError.text = item.unit.error?.message
@ -170,9 +176,11 @@ class LoginStudentSelectAdapter @Inject constructor() :
oldItem is LoginStudentSelectItem.SymbolHeader && newItem is LoginStudentSelectItem.SymbolHeader -> { oldItem is LoginStudentSelectItem.SymbolHeader && newItem is LoginStudentSelectItem.SymbolHeader -> {
oldItem.symbol == newItem.symbol oldItem.symbol == newItem.symbol
} }
oldItem is LoginStudentSelectItem.Student && newItem is LoginStudentSelectItem.Student -> { oldItem is LoginStudentSelectItem.Student && newItem is LoginStudentSelectItem.Student -> {
oldItem.student == newItem.student oldItem.student == newItem.student
} }
else -> oldItem == newItem else -> oldItem == newItem
} }

View File

@ -12,6 +12,7 @@ import io.github.wulkanowy.data.pojos.RegisterUser
import io.github.wulkanowy.data.repositories.SchoolsRepository import io.github.wulkanowy.data.repositories.SchoolsRepository
import io.github.wulkanowy.data.repositories.StudentRepository import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.data.resourceFlow import io.github.wulkanowy.data.resourceFlow
import io.github.wulkanowy.sdk.scrapper.exception.StudentGraduateException
import io.github.wulkanowy.sdk.scrapper.login.InvalidSymbolException import io.github.wulkanowy.sdk.scrapper.login.InvalidSymbolException
import io.github.wulkanowy.services.sync.SyncManager import io.github.wulkanowy.services.sync.SyncManager
import io.github.wulkanowy.ui.base.BasePresenter import io.github.wulkanowy.ui.base.BasePresenter
@ -108,8 +109,8 @@ class LoginStudentSelectPresenter @Inject constructor(
} }
private fun createItems(): List<LoginStudentSelectItem> = buildList { private fun createItems(): List<LoginStudentSelectItem> = buildList {
val notEmptySymbols = registerUser.symbols.filter { it.schools.isNotEmpty() } val notEmptySymbols = registerUser.symbols.filter { it.shouldShowOnTop() }
val emptySymbols = registerUser.symbols.filter { it.schools.isEmpty() } val emptySymbols = registerUser.symbols.filter { !it.shouldShowOnTop() }
if (emptySymbols.isNotEmpty() && notEmptySymbols.isNotEmpty() && emptySymbols.any { it.symbol == loginData.userEnteredSymbol }) { if (emptySymbols.isNotEmpty() && notEmptySymbols.isNotEmpty() && emptySymbols.any { it.symbol == loginData.userEnteredSymbol }) {
add(createEmptySymbolItem(emptySymbols.first { it.symbol == loginData.userEnteredSymbol })) add(createEmptySymbolItem(emptySymbols.first { it.symbol == loginData.userEnteredSymbol }))
@ -127,6 +128,10 @@ class LoginStudentSelectPresenter @Inject constructor(
add(helpItem) add(helpItem)
} }
private fun RegisterSymbol.shouldShowOnTop(): Boolean {
return schools.isNotEmpty() || error is StudentGraduateException
}
private fun createNotEmptySymbolItems( private fun createNotEmptySymbolItems(
notEmptySymbols: List<RegisterSymbol>, notEmptySymbols: List<RegisterSymbol>,
students: List<StudentWithSemesters>, students: List<StudentWithSemesters>,

View File

@ -23,7 +23,7 @@ fun getRefreshKey(name: String, semester: Semester): String {
} }
fun getRefreshKey(name: String, student: Student): String { fun getRefreshKey(name: String, student: Student): String {
return "${name}_${student.userLoginId}" return "${name}_${student.studentId}"
} }
fun getRefreshKey(name: String, mailbox: Mailbox?, folder: MessageFolder): String { fun getRefreshKey(name: String, mailbox: Mailbox?, folder: MessageFolder): String {

View File

@ -18,7 +18,7 @@ fun Semester.isCurrent(now: LocalDate = now()): Boolean {
} }
fun List<Semester>.getCurrentOrLast(): Semester { fun List<Semester>.getCurrentOrLast(): Semester {
if (isEmpty()) throw RuntimeException("Empty semester list") if (isEmpty()) throw IllegalStateException("Empty semester list")
// when there is only one current semester // when there is only one current semester
singleOrNull { it.isCurrent() }?.let { return it } singleOrNull { it.isCurrent() }?.let { return it }

View File

@ -1,8 +1,5 @@
Wersja 2.5.3 Wersja 2.5.6
— naprawiliśmy wyświetlanie błędu "Brak uprawnień" po starcie aplikacji u użytkowników eduOne — naprawiliśmy logowanie (pusta lista z wyborem uczniów), które zepsuło się po zmianach po stronie VULCANa
— naprawiliśmy obsługę autoryzacji u użytkowników eduOne
— ukryliśmy numery lekcji i oceny klasy u użytkowników eduOne, bo VULCAN te funkcje usunął
— naprawiliśmy inne rzeczy u użytkowników eduOne (jak brak opisu oceny czy ładowanie danych na kilku ekranach)
Pełna lista zmian: https://github.com/wulkanowy/wulkanowy/releases Pełna lista zmian: https://github.com/wulkanowy/wulkanowy/releases