1
0

Compare commits

...

158 Commits
1.2.1 ... 1.4.3

Author SHA1 Message Date
a56f4b8745 Merge branch 'release/1.4.3' 2021-11-26 22:29:09 +01:00
d003b0897c Version 1.4.3 2021-11-26 22:29:03 +01:00
581bb2de77 New Crowdin updates (#1669) 2021-11-26 20:22:54 +01:00
495e385228 Fix snackbar crash in grade statistics view (#1682) 2021-11-25 23:48:08 +01:00
10ba36ba44 Bump hianalytics from 6.3.0.303 to 6.3.2.300 (#1684) 2021-11-25 22:46:30 +00:00
eae396424f Bump hilt_version from 2.40.1 to 2.40.2 (#1683) 2021-11-25 22:46:17 +00:00
a7891bb266 Update viewpager2 library to fix duplicated menu bug (#1681) 2021-11-24 09:53:16 +01:00
6e82409dbc Bump play-services-ads from 20.4.0 to 20.5.0 (#1675) 2021-11-23 02:02:03 +00:00
984db18be3 Bump agcp from 1.6.1.300 to 1.6.2.200 (#1674) 2021-11-23 01:55:49 +00:00
c99bc96c08 Bump logging-interceptor from 4.9.2 to 4.9.3 (#1676) 2021-11-23 01:55:10 +00:00
3e7030abc2 Bump agconnect-crash from 1.6.1.300 to 1.6.2.200 (#1677) 2021-11-23 01:54:47 +00:00
6dad3b299b Bump robolectric from 4.7 to 4.7.2 (#1678) 2021-11-23 01:54:26 +00:00
5e997f5a3e Merge branch 'release/1.4.2' into develop 2021-11-21 13:31:56 +01:00
601d573283 Merge branch 'release/1.4.2' 2021-11-21 13:31:51 +01:00
6ae6ca7fbb Version 1.4.2 2021-11-21 13:31:45 +01:00
c3d38afc3d New Crowdin updates (#1658) 2021-11-21 13:23:43 +01:00
4e19964249 Make admin messages dissmisable (#1661) 2021-11-21 09:02:12 +01:00
a6c0efcb81 Fix empty student list in LoginStudentSelect view (#1668) 2021-11-21 07:47:23 +00:00
fcc71c0d5f Add ads limit (#1662) 2021-11-21 08:34:28 +01:00
a59d10b6c1 Disable personalized ads in single support advert (#1665) 2021-11-20 16:46:14 +01:00
a48e4eb4ee Probably fix snackbar crash in grade statistics view (#1663) 2021-11-20 16:42:21 +01:00
2a3668bb18 Fix nul login data in login symbol view (#1664) 2021-11-20 16:41:12 +01:00
804d0d9113 Add multiline to support ad preference (#1651) 2021-11-18 20:23:09 +01:00
88b893e6c0 Fix upcoming lesson notifications on Android 12 (#1650) 2021-11-18 20:22:15 +01:00
2874a7495e Add Czech and Slovak README (#1631) 2021-11-18 19:38:51 +01:00
40d8f7a93d German readme version (#1629) 2021-11-18 16:31:59 +01:00
84cd51205f Bump appcompat from 1.4.0-rc01 to 1.4.0 (#1654) 2021-11-18 01:02:12 +00:00
bac1832f27 Bump mockk from 1.12.0 to 1.12.1 (#1653) 2021-11-18 00:40:59 +00:00
8bf1e22407 Bump flow-preferences from 1.5.0 to 1.6.0 (#1657) 2021-11-18 00:40:38 +00:00
e9f43f925c Bump constraintlayout from 2.1.1 to 2.1.2 (#1656) 2021-11-18 00:37:26 +00:00
aa632edf5c Bump fragment-ktx from 1.4.0-rc01 to 1.4.0 (#1655) 2021-11-18 00:36:18 +00:00
57315d75c6 Bump work_manager from 2.7.0 to 2.7.1 (#1652) 2021-11-18 00:36:00 +00:00
4552bc85b0 Bump kotlin_version from 1.5.31 to 1.6.0 (#1635) 2021-11-18 01:34:55 +01:00
6b7795118c Merge branch 'release/1.4.1' into develop 2021-11-16 23:29:22 +01:00
1960782d8e Merge branch 'release/1.4.1' 2021-11-16 23:29:17 +01:00
8836be3766 Version 1.4.1 2021-11-16 23:29:12 +01:00
8697993149 Add missing env for google play build (#1647) 2021-11-16 23:28:03 +01:00
b88c7eb4e4 Merge branch 'release/1.4.0' into develop 2021-11-16 22:49:58 +01:00
9066bce0d5 Merge branch 'release/1.4.0' 2021-11-16 22:49:53 +01:00
f15b90782a Version 1.4.0 2021-11-16 22:40:30 +01:00
0642bf7d73 New Crowdin updates (#1646) 2021-11-16 22:38:53 +01:00
981d6d559c Login improvements (#1645) 2021-11-16 21:05:00 +00:00
39327ff3ea School and teachers UI fixes (#1644) 2021-11-16 21:45:14 +01:00
b098ac029b Fix displaying errors in GradeDetailsPresenter (#1600) 2021-11-16 20:42:06 +00:00
7e0e2fbb67 Fix saved state in main and splash activity (#1633) 2021-11-16 18:13:10 +01:00
8a181c747c Remove deprecated usage of LifecycleObserver (#1641) 2021-11-16 00:38:52 +01:00
68fdb167c2 Sort items in homework and grade tiles on the dashboard (#1634) 2021-11-15 22:35:01 +01:00
17563d1a4b Bump hianalytics from 6.3.0.302 to 6.3.0.303 (#1636) 2021-11-15 13:56:39 +00:00
370881104e Bump hilt_version from 2.40 to 2.40.1 (#1637) 2021-11-15 13:56:03 +00:00
62b1b18326 Bump kotlinx-serialization-json from 1.3.0 to 1.3.1 (#1638) 2021-11-15 13:55:45 +00:00
214e43bd4b Bump robolectric from 4.6.1 to 4.7 (#1639) 2021-11-15 13:55:18 +00:00
358e0850ad Bump appcompat from 1.4.0-beta01 to 1.4.0-rc01 (#1640) 2021-11-15 13:40:51 +00:00
c183428107 Prevent changing the current day by accident when excusing absences (#1599) 2021-11-13 19:56:17 +01:00
6de937703a Fix admin messages caching (#1632) 2021-11-13 19:50:26 +01:00
10cb2b70f1 New Crowdin updates (#1630) 2021-11-13 10:36:19 +01:00
9230db3f99 Do not show student's name in notifications if there's only one student (#1609) 2021-11-13 10:06:59 +01:00
66e58ab74e Bump activity-ktx from 1.3.1 to 1.4.0 (#1620) 2021-11-13 03:15:14 +00:00
99b7af64c0 Bump lifecycle-livedata-ktx from 2.3.1 to 2.4.0 (#1618) 2021-11-13 02:25:38 +00:00
4295dd6246 New Crowdin updates (#1566) 2021-11-11 15:56:56 +01:00
fb2d92c749 Fix homework sync (#1627) 2021-11-11 15:52:46 +01:00
07b1969a35 Bump core-ktx from 1.6.0 to 1.7.0 (#1622) 2021-11-11 14:33:01 +00:00
886403bf1e Bump annotation from 1.2.0 to 1.3.0 (#1628) 2021-11-11 14:32:19 +00:00
e8b21c1429 Bump coil from 1.3.2 to 1.4.0 (#1573) 2021-11-11 14:32:04 +00:00
007d62e61d Update project to Android SDK 31 (#1570) 2021-11-11 15:23:20 +01:00
f88d44f0ec Add timetable changes, attendance notifications and refactor notification deeplinks (#1547) 2021-11-06 22:21:34 +01:00
4401df6203 Migrate from ViewPager to ViewPager2 (#1601) 2021-11-06 19:07:26 +01:00
e6f23ab35b Add support for user ca in debug flavor(#1624) 2021-11-04 03:09:47 +01:00
eea20ced57 Notifications settings reorganize and strings update (#1616) 2021-11-04 03:06:54 +01:00
b7134221cb Bump hilt_version from 2.39.1 to 2.40 (#1617) 2021-11-01 17:05:57 +00:00
8be605629a Bump firebase-crashlytics-gradle from 2.7.1 to 2.8.0 (#1623) 2021-11-01 16:56:18 +00:00
1a3d580116 Bump firebase-bom from 28.4.2 to 29.0.0 (#1619) 2021-11-01 16:54:43 +00:00
a62ed54d07 Bump hianalytics from 6.3.0.301 to 6.3.0.302 (#1621) 2021-11-01 16:54:10 +00:00
36a570eeb0 Allow selecting text in error dialog (#1615) 2021-11-01 01:46:23 +01:00
8ed8b5a33c Error messages content wrap in error dialog (#1577) 2021-10-31 20:28:01 +01:00
26c749c219 Fix about header layout to support long app names (for DEV builds) (#1602)
Co-authored-by: Mikołaj Pich <m.pich@outlook.com>
2021-10-29 20:30:27 +02:00
1d910f8d66 Fix excuse button showing up despite no lessons available to excuse (#1607)
This was happening when there was an unexcused lesson that you excused until the teacher sent a response (accepted or denied it)
2021-10-27 10:07:04 +02:00
de11644e9b Bump agconnect-crash from 1.6.1.200 to 1.6.1.300 (#1605) 2021-10-26 09:53:16 +00:00
621db49fbf Bump agcp from 1.6.1.200 to 1.6.1.300 (#1606) 2021-10-26 09:52:55 +00:00
b593795844 Bump about_libraries from 8.9.3 to 8.9.4 (#1604) 2021-10-26 09:51:36 +00:00
0f800b61f6 Allow expanding multiple subject' grades at once (#1584) 2021-10-24 01:23:36 +02:00
94fd303f8e Add single support advert (#1484) 2021-10-21 10:51:00 +02:00
09a134d442 Fix last sync date to save only successful sync (#1595) 2021-10-21 10:47:37 +02:00
58ea2c530e Bump hianalytics from 6.3.0.300 to 6.3.0.301 (#1593) 2021-10-19 06:36:46 +00:00
84e4167dbd Mi Band notification improvements (#1579) 2021-10-18 00:11:46 +02:00
54e9ea6478 Use FloatingActionButton.{show,hide} instead of using setVisibility (#1591) 2021-10-16 11:17:00 +02:00
4c8d9c8f7f Fix infinite refresh when admin messages list is empty (#1587) 2021-10-14 16:25:09 +02:00
e7550f7a43 Bump gradle from 7.0.2 to 7.0.3 (#1586) 2021-10-14 00:10:22 +00:00
ac86737050 Fix NPE in SyncPresenter (#1582) 2021-10-14 01:44:34 +02:00
e3122127c0 Add admin messages (#1553) 2021-10-13 23:58:24 +02:00
d6918077bf Set upcoming lesson notification visibility to public (#1581) 2021-10-13 21:47:03 +02:00
9d0366d010 Bump firebase-bom from 28.4.1 to 28.4.2 (#1574) 2021-10-11 15:44:19 +00:00
3d0cd11ba4 Bump Treessence from 1.0.4 to 1.0.5 (#1575) 2021-10-11 15:44:03 +00:00
64dbbd54b4 Bump hianalytics from 6.2.0.301 to 6.3.0.300 (#1576) 2021-10-11 15:43:29 +00:00
539be586ce Fix text color of time left indicator on dashboard timetable card (#1572) 2021-10-11 14:39:54 +02:00
a240fd5d5f Add build timestamp as build config field (#1567) 2021-10-09 18:37:27 +02:00
4e69cfe23c Change text when there are no lessons today and tomorrow in dashboard (#1571) 2021-10-09 15:36:22 +00:00
2ab0a57a41 Separate calculated average settings (#1558) 2021-10-09 01:55:00 +02:00
ebf9e741c2 Fix homework last item padding (#1568) 2021-10-09 01:04:04 +02:00
e8075e30e4 Add "add homework" feature (#1564) 2021-10-08 11:19:49 +02:00
1839d7cb8f Migrate from moshi to kotlinx serialization (#1557) 2021-10-04 17:13:31 +02:00
2d84b0775a Bump hilt_version from 2.38.1 to 2.39.1 (#1561) 2021-10-04 14:51:31 +00:00
426379ec17 Bump about_libraries from 8.9.1 to 8.9.3 (#1560) 2021-10-04 14:45:09 +00:00
8315759c83 Bump agcp from 1.6.0.300 to 1.6.1.200 (#1562) 2021-10-04 14:44:15 +00:00
04393e60bb Bump agconnect-crash from 1.6.0.300 to 1.6.1.200 (#1563) 2021-10-04 14:43:46 +00:00
031a17ea50 Change text to bold in notifications center (#1546) 2021-10-04 16:35:37 +02:00
60501fcd72 Set buildTimestamp through manifest meta (#1556) 2021-10-03 14:13:42 +02:00
8e607d48f7 Add coroutines scope to presenter (#1554) 2021-10-03 10:36:17 +02:00
e02d93f979 Add Czech and Slovak listings (#1555) 2021-10-03 01:07:45 +02:00
76514e2d72 Merge branch 'release/1.3.0' into develop 2021-09-28 23:26:22 +02:00
689012131f Merge branch 'release/1.3.0' 2021-09-28 23:26:18 +02:00
6cdcf92782 Version 1.3.0 2021-09-28 23:26:10 +02:00
9c8bcbfdd3 New Crowdin updates (#1544) 2021-09-28 21:11:59 +00:00
0b83a66b85 Remove disappearing teachers workaround from timetable repository (#1545) 2021-09-28 23:10:11 +02:00
9711cc868c New Crowdin updates (#1522) 2021-09-28 22:42:06 +02:00
f8cb7599e6 Add missing auto refresh to recipients, subjects and teachers (#1540) 2021-09-28 22:40:43 +02:00
7636618e23 Update License (#1542) 2021-09-28 21:55:40 +02:00
5bc54c12f1 Add option to make upcoming lesson notification not persistent (#1537) 2021-09-28 11:48:25 +02:00
e10e530dee Remove seconds from timetable timer (#1539) 2021-09-27 23:03:59 +02:00
d69118b085 Add notifications center (#1524) 2021-09-27 20:58:25 +02:00
dc90549b9d Fix hiding last element in messages (#1538) 2021-09-27 17:56:43 +02:00
b552dbc904 Bump constraintlayout from 2.1.0 to 2.1.1 (#1535) 2021-09-27 15:56:11 +00:00
a6a1678b47 Bump core from 1.10.1 to 1.10.2 (#1536) 2021-09-27 15:51:06 +00:00
7a46ef5f19 Add calculated average help dialog (#1379)
Co-authored-by: Rafał Borcz <RafalBO99@outlook.com>
2021-09-25 17:19:21 +02:00
f9e0f7b390 Don't stop loading the timetable when error occurs in upcoming lessons notification scheduling (#1532) 2021-09-25 15:18:40 +02:00
9211baf7ec Add notification piggyback (#1503) 2021-09-25 14:02:38 +02:00
de6131f4f5 Add transparency to lucky number widget (#1530) 2021-09-25 13:46:35 +02:00
2cb11e443c Mark teacher with yellow when new and old are the same (#1529) 2021-09-25 13:46:11 +02:00
a43ffcdef4 Display bad credentials error in the message box above login form (#1525) 2021-09-24 21:02:51 +02:00
6615e68430 Bump kotlin_version from 1.5.30 to 1.5.31 (#1528) 2021-09-22 09:25:54 +02:00
36daa7ccc1 Always include all language resources in app bundle (#1527) 2021-09-22 09:25:16 +02:00
6e5481f345 Upgrade Gradle Play Publisher to 3.6.0 (#1526) 2021-09-20 11:38:13 +02:00
ba1c14ca0e Merge branch 'release/1.2.3' into develop 2021-09-16 12:01:58 +02:00
c69bb2ef71 Merge branch 'release/1.2.3' 2021-09-16 12:01:54 +02:00
9cb4754132 Version 1.2.3 2021-09-16 12:01:49 +02:00
5ba8289c87 Display info in timetable as-is when lesson has change flag (#1521) 2021-09-16 11:59:23 +02:00
258782c648 New Crowdin updates (#1482) 2021-09-16 11:30:05 +02:00
c568bc1515 Fix ghost account after logout not current student (#1518) 2021-09-16 11:29:11 +02:00
da668f93cf Fix bugs in dashboard (#1517) 2021-09-16 11:24:52 +02:00
037dbd792f Add conference dialog (#1519) 2021-09-16 10:51:38 +02:00
7ec7afed87 Bump firebase-bom from 28.4.0 to 28.4.1 (#1520) 2021-09-16 08:22:06 +00:00
bea50e6db5 Merge branch 'release/1.2.2' into develop 2021-09-13 14:53:38 +02:00
6a00e75816 Merge branch 'release/1.2.2' 2021-09-13 14:53:32 +02:00
957adaf6ee Version 1.2.2 2021-09-13 14:53:27 +02:00
827fb33eeb Fix login process after was interrupted (#1505) 2021-09-13 14:36:31 +02:00
19c96ee83f Unlock sunday in navigation datepicker (#1506) 2021-09-13 14:19:46 +02:00
5a7f52c773 Update help email pre-filled content (#1507) 2021-09-13 14:19:24 +02:00
dddeff802f Fix date picker crash after saved state (#1502) 2021-09-12 17:29:46 +02:00
91f6310892 Restore lucky number in more view (#1504) 2021-09-11 19:43:05 +02:00
0389642543 Fix empty list on excuse submit (#1501) 2021-09-11 19:40:09 +02:00
8528e0beff Fix crash in school info when dialer is unavailable (#1500) 2021-09-10 09:49:22 +00:00
e665a8f18b Fix error view in attendance summary (#1492) 2021-09-10 00:48:29 +02:00
6d5acbad2c Fix overlapping error view (#1493) 2021-09-10 00:36:44 +02:00
7217d0f753 Fix NPE in timetable dashboard tile (#1498) 2021-09-10 00:27:48 +02:00
16a5d88dfb Fix overlapping shadow in dashboard (#1494) 2021-09-10 00:25:23 +02:00
646a46727f Update material chips input (#1495) 2021-09-08 09:13:52 +02:00
f5e9197f98 Bump work_manager from 2.5.0 to 2.6.0 (#1478) 2021-09-06 23:38:10 +00:00
b47f26684b Change AppGallery deploy format to aab (#1483) 2021-09-06 03:27:54 +02:00
3a03b5f1c6 Merge branch 'release/1.2.1' into develop 2021-09-05 23:29:30 +02:00
340 changed files with 18874 additions and 2582 deletions

View File

@ -28,15 +28,17 @@ jobs:
SERVICES_ENCRYPT_KEY: ${{ secrets.SERVICES_ENCRYPT_KEY }} SERVICES_ENCRYPT_KEY: ${{ secrets.SERVICES_ENCRYPT_KEY }}
run: | run: |
gpg --yes --batch --passphrase=$SERVICES_ENCRYPT_KEY ./app/src/release/google-services.json.gpg gpg --yes --batch --passphrase=$SERVICES_ENCRYPT_KEY ./app/src/release/google-services.json.gpg
gpg --yes --batch --passphrase=$ENCRYPT_KEY ./app/key.p12.gpg
gpg --yes --batch --passphrase=$ENCRYPT_KEY ./app/upload-key.jks.gpg gpg --yes --batch --passphrase=$ENCRYPT_KEY ./app/upload-key.jks.gpg
- name: Upload apk to google play - name: Upload apk to google play
env: env:
PLAY_STORE_PASSWORD: ${{ secrets.PLAY_STORE_PASSWORD }}
PLAY_KEY_ALIAS: ${{ secrets.PLAY_KEY_ALIAS }} PLAY_KEY_ALIAS: ${{ secrets.PLAY_KEY_ALIAS }}
PLAY_KEY_PASSWORD: ${{ secrets.PLAY_KEY_PASSWORD }} PLAY_KEY_PASSWORD: ${{ secrets.PLAY_KEY_PASSWORD }}
PLAY_SERVICE_ACCOUNT_EMAIL: ${{ secrets.PLAY_SERVICE_ACCOUNT_EMAIL }} ANDROID_PUBLISHER_CREDENTIALS: ${{ secrets.ANDROID_PUBLISHER_CREDENTIALS }}
PLAY_STORE_PASSWORD: ${{ secrets.PLAY_STORE_PASSWORD }} ADMOB_PROJECT_ID: ${{ secrets.ADMOB_PROJECT_ID }}
run: ./gradlew publishPlayRelease -PenableFirebase --stacktrace; SINGLE_SUPPORT_AD_ID: ${{ secrets.SINGLE_SUPPORT_AD_ID }}
SET_BUILD_TIMESTAMP: ${{ secrets.SET_BUILD_TIMESTAMP }}
run: ./gradlew publishPlayReleaseApps -PenableFirebase --stacktrace;
deploy-app-gallery: deploy-app-gallery:
name: Deploy to AppGallery name: Deploy to AppGallery
@ -60,7 +62,6 @@ jobs:
SERVICES_ENCRYPT_KEY: ${{ secrets.SERVICES_ENCRYPT_KEY }} SERVICES_ENCRYPT_KEY: ${{ secrets.SERVICES_ENCRYPT_KEY }}
run: | run: |
gpg --yes --batch --passphrase=$SERVICES_ENCRYPT_KEY ./app/src/release/agconnect-services.json.gpg gpg --yes --batch --passphrase=$SERVICES_ENCRYPT_KEY ./app/src/release/agconnect-services.json.gpg
gpg --yes --batch --passphrase=$ENCRYPT_KEY ./app/key.p12.gpg
gpg --yes --batch --passphrase=$ENCRYPT_KEY ./app/upload-key.jks.gpg gpg --yes --batch --passphrase=$ENCRYPT_KEY ./app/upload-key.jks.gpg
- name: Prepare credentials - name: Prepare credentials
env: env:
@ -68,7 +69,8 @@ jobs:
run: echo $AGC_CREDENTIALS > ./app/src/release/agconnect-credentials.json run: echo $AGC_CREDENTIALS > ./app/src/release/agconnect-credentials.json
- name: Build and publish HMS version - name: Build and publish HMS version
env: env:
PLAY_STORE_PASSWORD: ${{ secrets.PLAY_STORE_PASSWORD }}
PLAY_KEY_ALIAS: ${{ secrets.PLAY_KEY_ALIAS }} PLAY_KEY_ALIAS: ${{ secrets.PLAY_KEY_ALIAS }}
PLAY_KEY_PASSWORD: ${{ secrets.PLAY_KEY_PASSWORD }} PLAY_KEY_PASSWORD: ${{ secrets.PLAY_KEY_PASSWORD }}
PLAY_STORE_PASSWORD: ${{ secrets.PLAY_STORE_PASSWORD }} SET_BUILD_TIMESTAMP: ${{ secrets.SET_BUILD_TIMESTAMP }}
run: ./gradlew assembleHmsRelease --stacktrace && ./gradlew publishHuaweiAppGalleryHmsRelease --stacktrace run: ./gradlew bundleHmsRelease --stacktrace && ./gradlew publishHuaweiAppGalleryHmsRelease --stacktrace

View File

@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright 2019 Wulkanowy Copyright 2021 Wulkanowy
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

78
README.cs.md Normal file
View File

@ -0,0 +1,78 @@
[English version of README](README.en.md)
[Deutsche Version von README](README.de.md)
[Polska wersja README](README.md)
[Slovenská verzia README](README.sk.md)
# Wulkanowy
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/wulkanowy/wulkanowy/Tests/develop?style=flat-square)](https://github.com/wulkanowy/wulkanowy/actions)
[![Codecov](https://img.shields.io/codecov/c/github/wulkanowy/wulkanowy/master.svg?style=flat-square)](https://codecov.io/gh/wulkanowy/wulkanowy)
[![Discord](https://img.shields.io/discord/390889354199040011.svg?style=flat-square)](https://discord.gg/vccAQBr)
[![F-Droid](https://img.shields.io/f-droid/v/io.github.wulkanowy.svg?style=flat-square)](https://f-droid.org/packages/io.github.wulkanowy/)
[![Last release](https://img.shields.io/github/release/wulkanowy/wulkanowy.svg?logo=github&style=flat-square)](https://github.com/wulkanowy/wulkanowy/releases)
Neoficiální klient deníku VULCAN UONET+ pro žáka a rodiče
## Funkce
* přihlášení pomocí emailu a hesla
* funkce z webové stránky deníku:
* známky
* statistiky známek
* frekvence
* procento frekvence
* zkoušky
* plán lekce
* dokončené lekce
* zprávy
* domácí úkoly
* poznámky
* šťastné číslo
* další lekce
* školní setkání
* informace o žáku a škole
* výpočet průměru nezávisle na preferencích školy
* upozornění, např. o nových známkách
* podpora více účtů s možností přejmenování žáků
* tmavý a černý (AMOLED) motiv
* offline režim
* žádné reklamy
## Stáhnout
Aktuální verzi si můžete stáhnout z Google Play, F-Droid nebo Huawei AppGallery
[<img src="https://play.google.com/intl/cs-CZ/badges/images/generic/cs_badge_web_generic.png"
alt="Nyní na Google Play"
height="80">](https://play.google.com/store/apps/details?id=io.github.wulkanowy)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Stáhnout s F-Droid"
height="80">](https://f-droid.org/packages/io.github.wulkanowy/)
[<img src="https://i.imgur.com/baTGiDP.png"
alt="Objevuj v AppGallery"
height="80">](https://appgallery.cloud.huawei.com/ag/n/app/C101440411?channelId=Badge&id=1b3f7fbb700849a9be0dba6b520b2282&s=EB1D3BF9ED9D1564D869B7B94B18016D3CABFCA5AEFB8E29F675FA04E0DC131D&detailType=0&v=)
Můžete si také stáhnout [vývojovou verzi](https://wulkanowy.github.io/#download), která zahrnuje nové funkce připravované pro příští vydání
## Postaveno s
* [Wulkanowy SDK](https://github.com/wulkanowy/sdk)
* [Kotlin Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html)
* [Hilt](https://dagger.dev/hilt/)
* [Room](https://developer.android.com/topic/libraries/architecture/room)
* [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)
## Spolupráce
Přispějte do projektu vytvořením PR nebo odesláním issue na GitHub.
Pro zájemce o překlad aplikace do různých jazyků poskytujeme Crowdin:
https://crowdin.com/project/wulkanowy2
## Licence
Tento projekt je licencován pod licencí Apache License 2.0 - podrobnosti v souboru [LICENSE](LICENSE)

74
README.de.md Normal file
View File

@ -0,0 +1,74 @@
[Polska wersja README](README.md)
[English version of README](README.en.md)
# Wulkanowy
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/wulkanowy/wulkanowy/Tests/develop?style=flat-square)](https://github.com/wulkanowy/wulkanowy/actions)
[![Codecov](https://img.shields.io/codecov/c/github/wulkanowy/wulkanowy/master.svg?style=flat-square)](https://codecov.io/gh/wulkanowy/wulkanowy)
[![Discord](https://img.shields.io/discord/390889354199040011.svg?style=flat-square)](https://discord.gg/vccAQBr)
[![F-Droid](https://img.shields.io/f-droid/v/io.github.wulkanowy.svg?style=flat-square)](https://f-droid.org/packages/io.github.wulkanowy/)
[![Last release](https://img.shields.io/github/release/wulkanowy/wulkanowy.svg?logo=github&style=flat-square)](https://github.com/wulkanowy/wulkanowy/releases)
Inoffizieller Android VULCAN UONET+ Registrierungsclient für Schüler und ihre Eltern
## Merkmale
* Einloggen mit E-Mail und Passwort
* Funktionen von der Registerwebsite:
* Noten
* Notenstatistik
* Anwesenheit
* Prozentsatz der Anwesenheit
* Prüfungen
* Stundenplan
* Unterricht abgeschlossen
* Nachrichten
* Hausaufgaben
* Anmerkungen
* Glückszahl
* Zusätzliche Lektionen
* Schulkonferenzen
* Schüler- und Schulinformationen
* Berechnung des Durchschnitts unabhängig von den Präferenzen der Schule
* Benachrichtigungen, z. B. über eine neue Note
* Unterstützung für mehrere Konten mit der Möglichkeit, den Namen des Schülers zu ändern
* dunkles und schwarzes (AMOLED) Thema
* Offline-Modus
* keine Werbung
## Herunterladen
Die aktuelle Version können Sie von der Google Play, F-Droid oder Huawei AppGallery store herunterladen
[<img src="https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png"
alt="Get it on Google Play"
height="80">](https://play.google.com/store/apps/details?id=io.github.wulkanowy)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Get it on F-Droid"
height="80">](https://f-droid.org/packages/io.github.wulkanowy/)
[<img src="appgallery_badge.png"
alt="Explore it on AppGallery"
height="80">](https://appgallery.cloud.huawei.com/ag/n/app/C101440411?channelId=Badge&id=1b3f7fbb700849a9be0dba6b520b2282&s=EB1D3BF9ED9D1564D869B7B94B18016D3CABFCA5AEFB8E29F675FA04E0DC131D&detailType=0&v=)
Sie können auch ein [Entwicklungsversion herunterladen](https://wulkanowy.github.io/#download) das beinhaltet neue Funktionen, die für die nächste Version vorbereitet werden
## Gebaut mit
* [Wulkanowy SDK](https://github.com/wulkanowy/sdk)
* [Kotlin Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html)
* [Hilt](https://dagger.dev/hilt/)
* [Room](https://developer.android.com/topic/libraries/architecture/room)
* [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)
## Beitragen
Bitte tragen Sie zum Projekt bei, indem Sie entweder eine PR erstellen oder ein Issue auf GitHub einreichen.
Für Personen, die daran interessiert sind, die Anwendung in verschiedene Sprachen zu übersetzen, bieten wir Crowdin
https://crowdin.com/project/wulkanowy2
## Lizenz
Dieses Projekt ist unter der Apache License 2.0 lizenziert - siehe die [LIZENZ](LICENSE) Datei für Details

View File

@ -1,5 +1,11 @@
[Polska wersja README](README.md) [Polska wersja README](README.md)
[Deutsche Version von README](README.de.md)
[Česká verze README](README.cs.md)
[Slovenská verzia README](README.sk.md)
# Wulkanowy # Wulkanowy
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/wulkanowy/wulkanowy/Tests/develop?style=flat-square)](https://github.com/wulkanowy/wulkanowy/actions) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/wulkanowy/wulkanowy/Tests/develop?style=flat-square)](https://github.com/wulkanowy/wulkanowy/actions)

View File

@ -1,5 +1,11 @@
[English version of README](README.en.md) [English version of README](README.en.md)
[Deutsche Version von README](README.de.md)
[Česká verze README](README.cs.md)
[Slovenská verzia README](README.sk.md)
# Wulkanowy # Wulkanowy
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/wulkanowy/wulkanowy/Tests/develop?style=flat-square)](https://github.com/wulkanowy/wulkanowy/actions) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/wulkanowy/wulkanowy/Tests/develop?style=flat-square)](https://github.com/wulkanowy/wulkanowy/actions)

78
README.sk.md Normal file
View File

@ -0,0 +1,78 @@
[English version of README](README.en.md)
[Deutsche Version von README](README.de.md)
[Polska wersja README](README.md)
[Česká verze README](README.cs.md)
# Wulkanowy
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/wulkanowy/wulkanowy/Tests/develop?style=flat-square)](https://github.com/wulkanowy/wulkanowy/actions)
[![Codecov](https://img.shields.io/codecov/c/github/wulkanowy/wulkanowy/master.svg?style=flat-square)](https://codecov.io/gh/wulkanowy/wulkanowy)
[![Discord](https://img.shields.io/discord/390889354199040011.svg?style=flat-square)](https://discord.gg/vccAQBr)
[![F-Droid](https://img.shields.io/f-droid/v/io.github.wulkanowy.svg?style=flat-square)](https://f-droid.org/packages/io.github.wulkanowy/)
[![Last release](https://img.shields.io/github/release/wulkanowy/wulkanowy.svg?logo=github&style=flat-square)](https://github.com/wulkanowy/wulkanowy/releases)
Neoficiálny klient denníka VULCAN UONET+ pre žiaka a rodičov
## Funkcie
* prihlásenie pomocou emailu a hesla
* funkcie z webovej stránky denníka:
* známky
* štatistiky známok
* frekvencia
* percento frekvencie
* skúšky
* plán lekcie
* dokončené lekcie
* správy
* domáce úlohy
* poznámky
* šťastné číslo
* ďalšie lekcie
* školské stretnutie
* informácie o žiakovi a škole
* výpočet priemeru nezávisle od preferencií školy
* upozornenia, napr. o nových známkach
* podpora viacerých účtov s možnosťou premenovania žiakov
* tmavý a čierny (AMOLED) motív
* offline režim
* žiadne reklamy
## Stiahnuť
Aktuálnu verziu si môžete stiahnuť z Google Play, F-Droid alebo Huawei AppGallery
[<img src="https://play.google.com/intl/sk/badges/images/generic/sk_badge_web_generic.png"
alt="Nyní na Google Play"
height="80">](https://play.google.com/store/apps/details?id=io.github.wulkanowy)
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png"
alt="Stiahnuť s F-Droid"
height="80">](https://f-droid.org/packages/io.github.wulkanowy/)
[<img src="https://i.imgur.com/sX8UyAw.png"
alt="Objavíte v AppGallery"
height="80">](https://appgallery.cloud.huawei.com/ag/n/app/C101440411?channelId=Badge&id=1b3f7fbb700849a9be0dba6b520b2282&s=EB1D3BF9ED9D1564D869B7B94B18016D3CABFCA5AEFB8E29F675FA04E0DC131D&detailType=0&v=)
Môžete si tiež stiahnuť [vývojovú verziu](https://wulkanowy.github.io/#download), ktorá zahrňuje nové funkcie pripravované pre budúce vydanie
## Postavené s
* [Wulkanowy SDK](https://github.com/wulkanowy/sdk)
* [Kotlin Coroutines](https://kotlinlang.org/docs/reference/coroutines-overview.html)
* [Hilt](https://dagger.dev/hilt/)
* [Room](https://developer.android.com/topic/libraries/architecture/room)
* [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager)
## Spolupráca
Prispejte do projektu vytvorením PR alebo odoslaním issue na GitHub.
Pre záujemcov o preklad aplikácie do rôznych jazykov poskytujeme Crowdin:
https://crowdin.com/project/wulkanowy2
## Licencia
Tento projekt je licencovaný pod licenciou Apache License 2.0 - podrobnosti v súbore [LICENSE](LICENSE)

View File

@ -1,5 +1,6 @@
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-parcelize'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin' apply plugin: 'dagger.hilt.android.plugin'
@ -14,23 +15,22 @@ apply from: 'sonarqube.gradle'
apply from: 'hooks.gradle' apply from: 'hooks.gradle'
android { android {
compileSdkVersion 30 compileSdkVersion 31
defaultConfig { defaultConfig {
applicationId "io.github.wulkanowy" applicationId "io.github.wulkanowy"
testApplicationId "io.github.tests.wulkanowy" testApplicationId "io.github.tests.wulkanowy"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 30 targetSdkVersion 31
versionCode 94 versionCode 101
versionName "1.2.1" versionName "1.4.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
resValue "string", "app_name", "Wulkanowy" resValue "string", "app_name", "Wulkanowy"
buildConfigField "long", "BUILD_TIMESTAMP", String.valueOf(System.currentTimeMillis())
manifestPlaceholders = [ manifestPlaceholders = [
firebase_enabled: project.hasProperty("enableFirebase") firebase_enabled: project.hasProperty("enableFirebase"),
admob_project_id: ""
] ]
javaCompileOptions { javaCompileOptions {
annotationProcessorOptions { annotationProcessorOptions {
@ -40,6 +40,14 @@ android {
] ]
} }
} }
buildConfigField "String", "SINGLE_SUPPORT_AD_ID", "null"
if (System.env.SET_BUILD_TIMESTAMP) {
buildConfigField "long", "BUILD_TIMESTAMP", String.valueOf(System.currentTimeMillis())
} else {
buildConfigField "long", "BUILD_TIMESTAMP", "1486235849000"
}
} }
sourceSets { sourceSets {
@ -62,12 +70,14 @@ android {
shrinkResources true shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release signingConfig signingConfigs.release
buildConfigField "String", "MESSAGES_BASE_URL", "\"https://messages.wulkanowy.net.pl\""
} }
debug { debug {
resValue "string", "app_name", "Wulkanowy DEV " + defaultConfig.versionCode resValue "string", "app_name", "Wulkanowy DEV"
applicationIdSuffix ".dev" applicationIdSuffix ".dev"
versionNameSuffix "-dev" versionNameSuffix "-dev"
ext.enableCrashlytics = project.hasProperty("enableFirebase") ext.enableCrashlytics = project.hasProperty("enableFirebase")
buildConfigField "String", "MESSAGES_BASE_URL", "\"https://messages.wulkanowy.net.pl\""
} }
} }
@ -76,30 +86,38 @@ android {
productFlavors { productFlavors {
hms { hms {
dimension "platform" dimension "platform"
manifestPlaceholders = [ manifestPlaceholders = [install_channel: "AppGallery"]
install_channel: "AppGallery"
]
} }
play { play {
dimension "platform" dimension "platform"
manifestPlaceholders = [ manifestPlaceholders = [
install_channel: "Google Play" install_channel : "Google Play",
admob_project_id: System.getenv("ADMOB_PROJECT_ID") ?: "ca-app-pub-3940256099942544~3347511713"
] ]
buildConfigField "String", "SINGLE_SUPPORT_AD_ID", "\"${System.getenv("SINGLE_SUPPORT_AD_ID") ?: "ca-app-pub-3940256099942544/5354046379"}\""
} }
fdroid { fdroid {
dimension "platform" dimension "platform"
manifestPlaceholders = [ manifestPlaceholders = [install_channel: "F-Droid"]
install_channel: "F-Droid"
]
} }
} }
playConfigs {
play { enabled.set(true) }
}
buildFeatures { buildFeatures {
viewBinding true viewBinding true
} }
bundle {
language {
enableSplit = false
}
}
testOptions.unitTests { testOptions.unitTests {
includeAndroidResources = true includeAndroidResources = true
} }
@ -130,61 +148,61 @@ kapt {
} }
play { play {
serviceAccountEmail = System.getenv("PLAY_SERVICE_ACCOUNT_EMAIL") ?: "jan@fakelog.cf"
serviceAccountCredentials = file('key.p12')
defaultToAppBundles = false defaultToAppBundles = false
track = 'production' track = 'beta'
updatePriority = 3 updatePriority = 1
enabled.set(false)
} }
huaweiPublish { huaweiPublish {
instances { instances {
hmsRelease { hmsRelease {
credentialsPath = "$rootDir/app/src/release/agconnect-credentials.json" credentialsPath = "$rootDir/app/src/release/agconnect-credentials.json"
buildFormat = "apk" buildFormat = "aab"
deployType = "draft" deployType = "draft"
} }
} }
} }
ext { ext {
work_manager = "2.5.0" work_manager = "2.7.1"
android_hilt = "1.0.0" android_hilt = "1.0.0"
room = "2.3.0" room = "2.3.0"
chucker = "3.5.2" chucker = "3.5.2"
mockk = "1.12.0" mockk = "1.12.1"
moshi = "1.12.0" coroutines = "1.5.2"
} }
dependencies { dependencies {
implementation "io.github.wulkanowy:sdk:1.2.1" implementation "io.github.wulkanowy:sdk:1.4.3"
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines"
implementation "androidx.core:core-ktx:1.6.0" implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.activity:activity-ktx:1.3.1" implementation 'androidx.core:core-splashscreen:1.0.0-alpha02'
implementation "androidx.appcompat:appcompat:1.3.1" implementation "androidx.activity:activity-ktx:1.4.0"
implementation "androidx.appcompat:appcompat-resources:1.3.1" implementation "androidx.appcompat:appcompat:1.4.0"
implementation "androidx.fragment:fragment-ktx:1.3.6" implementation "androidx.fragment:fragment-ktx:1.4.0"
implementation "androidx.annotation:annotation:1.2.0" implementation "androidx.annotation:annotation:1.3.0"
implementation "androidx.preference:preference-ktx:1.1.1" implementation "androidx.preference:preference-ktx:1.1.1"
implementation "androidx.recyclerview:recyclerview:1.2.1" implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation "androidx.viewpager:viewpager:1.0.0" implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
implementation "androidx.constraintlayout:constraintlayout:2.1.0" implementation "androidx.constraintlayout:constraintlayout:2.1.2"
implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0" implementation "androidx.coordinatorlayout:coordinatorlayout:1.1.0"
implementation "com.google.android.material:material:1.4.0" implementation "com.google.android.material:material:1.4.0"
implementation "com.github.wulkanowy:material-chips-input:2.2.0" implementation "com.github.wulkanowy:material-chips-input:2.3.1"
implementation "com.github.PhilJay:MPAndroidChart:v3.1.0" implementation "com.github.PhilJay:MPAndroidChart:v3.1.0"
implementation 'com.github.lopspower:CircularImageView:4.2.0' implementation 'com.github.lopspower:CircularImageView:4.2.0'
implementation "androidx.work:work-runtime-ktx:$work_manager" implementation "androidx.work:work-runtime-ktx:$work_manager"
playImplementation "androidx.work:work-gcm:$work_manager" playImplementation "androidx.work:work-gcm:$work_manager"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0"
implementation "androidx.room:room-runtime:$room" implementation "androidx.room:room-runtime:$room"
implementation "androidx.room:room-ktx:$room" implementation "androidx.room:room-ktx:$room"
@ -198,40 +216,41 @@ dependencies {
implementation 'com.github.ncapdevi:FragNav:3.3.0' implementation 'com.github.ncapdevi:FragNav:3.3.0'
implementation "com.github.YarikSOffice:lingver:1.3.0" implementation "com.github.YarikSOffice:lingver:1.3.0"
implementation "com.squareup.moshi:moshi:$moshi" implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation "com.squareup.moshi:moshi-adapters:$moshi" implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi" implementation "com.squareup.okhttp3:logging-interceptor:4.9.3"
implementation "com.jakewharton.timber:timber:5.0.1" implementation "com.jakewharton.timber:timber:5.0.1"
implementation "at.favre.lib:slf4j-timber:1.0.1" implementation "at.favre.lib:slf4j-timber:1.0.1"
implementation 'com.github.bastienpaulfr:Treessence:1.0.4' implementation 'com.github.bastienpaulfr:Treessence:1.0.5'
implementation "com.mikepenz:aboutlibraries-core:$about_libraries" implementation "com.mikepenz:aboutlibraries-core:$about_libraries"
implementation "io.coil-kt:coil:1.3.2" implementation "io.coil-kt:coil:1.4.0"
implementation "io.github.wulkanowy:AppKillerManager:3.0.0" implementation "io.github.wulkanowy:AppKillerManager:3.0.0"
implementation 'me.xdrop:fuzzywuzzy:1.3.1' implementation 'me.xdrop:fuzzywuzzy:1.3.1'
implementation 'com.fredporciuncula:flow-preferences:1.5.0' implementation 'com.fredporciuncula:flow-preferences:1.6.0'
playImplementation platform('com.google.firebase:firebase-bom:28.4.0') playImplementation platform('com.google.firebase:firebase-bom:29.0.0')
playImplementation 'com.google.firebase:firebase-analytics-ktx' playImplementation 'com.google.firebase:firebase-analytics-ktx'
playImplementation 'com.google.firebase:firebase-messaging:' playImplementation 'com.google.firebase:firebase-messaging:'
playImplementation 'com.google.firebase:firebase-crashlytics:' playImplementation 'com.google.firebase:firebase-crashlytics:'
playImplementation 'com.google.android.play:core:1.10.1' playImplementation 'com.google.android.play:core:1.10.2'
playImplementation 'com.google.android.play:core-ktx:1.8.1' playImplementation 'com.google.android.play:core-ktx:1.8.1'
playImplementation 'com.google.android.gms:play-services-ads:20.5.0'
hmsImplementation 'com.huawei.hms:hianalytics:6.2.0.301' hmsImplementation 'com.huawei.hms:hianalytics:6.3.2.300'
hmsImplementation 'com.huawei.agconnect:agconnect-crash:1.6.0.300' hmsImplementation 'com.huawei.agconnect:agconnect-crash:1.6.2.200'
releaseImplementation "com.github.ChuckerTeam.Chucker:library-no-op:$chucker" releaseImplementation "com.github.ChuckerTeam.Chucker:library-no-op:$chucker"
debugImplementation "com.github.ChuckerTeam.Chucker:library:$chucker" debugImplementation "com.github.ChuckerTeam.Chucker:library:$chucker"
debugImplementation 'com.github.amitshekhariitbhu.Android-Debug-Database:debug-db:v1.0.6' debugImplementation 'com.github.amitshekhariitbhu.Android-Debug-Database:debug-db:1.0.6'
testImplementation "junit:junit:4.13.2" testImplementation "junit:junit:4.13.2"
testImplementation "io.mockk:mockk:$mockk" testImplementation "io.mockk:mockk:$mockk"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2' testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines"
testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version" testImplementation "org.jetbrains.kotlin:kotlin-test:$kotlin_version"
testImplementation 'org.robolectric:robolectric:4.6.1' testImplementation 'org.robolectric:robolectric:4.7.2'
testImplementation "androidx.test:runner:1.4.0" testImplementation "androidx.test:runner:1.4.0"
testImplementation "androidx.test.ext:junit:1.1.3" testImplementation "androidx.test.ext:junit:1.1.3"
testImplementation "androidx.test:core:1.4.0" testImplementation "androidx.test:core:1.4.0"

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<queries> <queries>
<intent> <intent>
@ -38,13 +39,14 @@
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="false" android:supportsRtl="false"
android:theme="@style/WulkanowyTheme" android:theme="@style/WulkanowyTheme"
android:usesCleartextTraffic="true"
tools:ignore="GoogleAppIndexingWarning,UnusedAttribute"> tools:ignore="GoogleAppIndexingWarning,UnusedAttribute">
<activity <activity
android:name=".ui.modules.splash.SplashActivity" android:name=".ui.modules.splash.SplashActivity"
android:exported="true"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/WulkanowyTheme.SplashScreen" android:theme="@style/WulkanowyTheme.SplashScreen"
tools:ignore="LockedOrientationActivity"> tools:ignore="LockedOrientationActivity">
@ -74,6 +76,7 @@
<activity <activity
android:name=".ui.modules.timetablewidget.TimetableWidgetConfigureActivity" android:name=".ui.modules.timetablewidget.TimetableWidgetConfigureActivity"
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:exported="true"
android:noHistory="true" android:noHistory="true"
android:theme="@style/WulkanowyTheme.WidgetAccountSwitcher"> android:theme="@style/WulkanowyTheme.WidgetAccountSwitcher">
<intent-filter> <intent-filter>
@ -83,6 +86,7 @@
<activity <activity
android:name=".ui.modules.luckynumberwidget.LuckyNumberWidgetConfigureActivity" android:name=".ui.modules.luckynumberwidget.LuckyNumberWidgetConfigureActivity"
android:excludeFromRecents="true" android:excludeFromRecents="true"
android:exported="true"
android:noHistory="true" android:noHistory="true"
android:theme="@style/WulkanowyTheme.WidgetAccountSwitcher"> android:theme="@style/WulkanowyTheme.WidgetAccountSwitcher">
<intent-filter> <intent-filter>
@ -93,6 +97,23 @@
<service <service
android:name=".services.widgets.TimetableWidgetService" android:name=".services.widgets.TimetableWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service
android:name=".services.piggyback.VulcanNotificationListenerService"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<service
android:name=".services.messaging.AppMessagingService"
android:exported="false"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver <receiver
android:name=".ui.modules.timetablewidget.TimetableWidgetProvider" android:name=".ui.modules.timetablewidget.TimetableWidgetProvider"
@ -107,6 +128,7 @@
</receiver> </receiver>
<receiver <receiver
android:name=".ui.modules.luckynumberwidget.LuckyNumberWidgetProvider" android:name=".ui.modules.luckynumberwidget.LuckyNumberWidgetProvider"
android:exported="true"
android:label="@string/lucky_number_title"> android:label="@string/lucky_number_title">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@ -119,11 +141,9 @@
<receiver android:name=".services.alarm.TimetableNotificationReceiver" /> <receiver android:name=".services.alarm.TimetableNotificationReceiver" />
<provider <provider
android:name="androidx.work.impl.WorkManagerInitializer" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.workmanager-init" android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="remove" /> tools:node="remove" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@ -134,44 +154,44 @@
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
<meta-data
android:name="install_channel"
android:value="${install_channel}" />
<!-- workaround for https://github.com/firebase/firebase-android-sdk/issues/473 enabled:false --> <!-- workaround for https://github.com/firebase/firebase-android-sdk/issues/473 enabled:false -->
<!-- https://firebase.googleblog.com/2017/03/take-control-of-your-firebase-init-on.html --> <!-- https://firebase.googleblog.com/2017/03/take-control-of-your-firebase-init-on.html -->
<provider <provider
android:name="com.google.firebase.provider.FirebaseInitProvider" android:name="com.google.firebase.provider.FirebaseInitProvider"
android:authorities="${applicationId}.firebaseinitprovider" android:authorities="${applicationId}.firebaseinitprovider"
android:enabled="${firebase_enabled}" android:enabled="${firebase_enabled}"
android:exported="false" /> android:exported="false"
tools:ignore="MissingClass" />
<meta-data
android:name="install_channel"
android:value="${install_channel}" />
<meta-data <meta-data
android:name="firebase_analytics_collection_enabled" android:name="firebase_analytics_collection_enabled"
android:value="${firebase_enabled}" /> android:value="${firebase_enabled}" />
<meta-data <meta-data
android:name="google_analytics_adid_collection_enabled" android:name="google_analytics_adid_collection_enabled"
android:value="${firebase_enabled}" /> android:value="${firebase_enabled}" />
<meta-data <meta-data
android:name="firebase_crashlytics_collection_enabled" android:name="firebase_crashlytics_collection_enabled"
android:value="${firebase_enabled}" /> android:value="${firebase_enabled}" />
<meta-data <meta-data
android:name="firebase_messaging_auto_init_enabled" android:name="firebase_messaging_auto_init_enabled"
android:value="${firebase_enabled}" /> android:value="${firebase_enabled}" />
<meta-data <meta-data
android:name="firebase_inapp_messaging_auto_data_collection_enabled" android:name="firebase_inapp_messaging_auto_data_collection_enabled"
android:value="${firebase_enabled}" /> android:value="${firebase_enabled}" />
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_stat_all" /> android:resource="@drawable/ic_stat_all" />
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id" android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="push_channel" /> android:value="push_channel" />
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="${admob_project_id}" />
<meta-data
android:name="com.google.android.gms.ads.DELAY_APP_MEASUREMENT_INIT"
android:value="true" />
</application> </application>
</manifest> </manifest>

View File

@ -1,12 +1,10 @@
package io.github.wulkanowy package io.github.wulkanowy
import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.util.Log.DEBUG import android.util.Log.DEBUG
import android.util.Log.INFO import android.util.Log.INFO
import android.util.Log.VERBOSE import android.util.Log.VERBOSE
import android.webkit.WebView import android.webkit.WebView
import androidx.fragment.app.FragmentManager
import androidx.hilt.work.HiltWorkerFactory import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration import androidx.work.Configuration
import com.yariksoffice.lingver.Lingver import com.yariksoffice.lingver.Lingver
@ -41,10 +39,8 @@ class WulkanowyApp : Application(), Configuration.Provider {
@Inject @Inject
lateinit var analyticsHelper: AnalyticsHelper lateinit var analyticsHelper: AnalyticsHelper
@SuppressLint("UnsafeOptInUsageWarning")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
FragmentManager.enableNewStateManager(false)
initializeAppLanguage() initializeAppLanguage()
themeManager.applyDefaultTheme() themeManager.applyDefaultTheme()
initLogging() initLogging()

View File

@ -2,62 +2,100 @@ package io.github.wulkanowy.data
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.AssetManager
import android.content.res.Resources
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.chuckerteam.chucker.api.ChuckerCollector import com.chuckerteam.chucker.api.ChuckerCollector
import com.chuckerteam.chucker.api.ChuckerInterceptor import com.chuckerteam.chucker.api.ChuckerInterceptor
import com.chuckerteam.chucker.api.RetentionManager import com.chuckerteam.chucker.api.RetentionManager
import com.squareup.moshi.Moshi
import com.fredporciuncula.flow.preferences.FlowSharedPreferences import com.fredporciuncula.flow.preferences.FlowSharedPreferences
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import io.github.wulkanowy.data.api.AdminMessageService
import io.github.wulkanowy.data.db.AppDatabase import io.github.wulkanowy.data.db.AppDatabase
import io.github.wulkanowy.data.db.SharedPrefProvider import io.github.wulkanowy.data.db.SharedPrefProvider
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AppInfo import io.github.wulkanowy.utils.AppInfo
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
internal class RepositoryModule { internal class DataModule {
@Singleton @Singleton
@Provides @Provides
fun provideSdk(chuckerCollector: ChuckerCollector, @ApplicationContext context: Context): Sdk { fun provideSdk(chuckerInterceptor: ChuckerInterceptor) =
return Sdk().apply { Sdk().apply {
androidVersion = android.os.Build.VERSION.RELEASE androidVersion = android.os.Build.VERSION.RELEASE
buildTag = android.os.Build.MODEL buildTag = android.os.Build.MODEL
setSimpleHttpLogger { Timber.d(it) } setSimpleHttpLogger { Timber.d(it) }
// for debug only // for debug only
addInterceptor( addInterceptor(chuckerInterceptor, network = true)
ChuckerInterceptor.Builder(context)
.collector(chuckerCollector)
.alwaysReadResponseBody(true)
.build(), network = true
)
} }
}
@Singleton @Singleton
@Provides @Provides
fun provideChuckerCollector( fun provideChuckerCollector(
@ApplicationContext context: Context, @ApplicationContext context: Context,
prefRepository: PreferencesRepository prefRepository: PreferencesRepository
): ChuckerCollector { ) = ChuckerCollector(
return ChuckerCollector( context = context,
context = context, showNotification = prefRepository.isDebugNotificationEnable,
showNotification = prefRepository.isDebugNotificationEnable, retentionPeriod = RetentionManager.Period.ONE_HOUR
retentionPeriod = RetentionManager.Period.ONE_HOUR )
)
} @Singleton
@Provides
fun provideChuckerInterceptor(
@ApplicationContext context: Context,
chuckerCollector: ChuckerCollector
) = ChuckerInterceptor.Builder(context)
.collector(chuckerCollector)
.alwaysReadResponseBody(true)
.build()
@Singleton
@Provides
fun provideOkHttpClient(chuckerInterceptor: ChuckerInterceptor): OkHttpClient =
OkHttpClient.Builder()
.addNetworkInterceptor(chuckerInterceptor)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
@OptIn(ExperimentalSerializationApi::class)
@Singleton
@Provides
fun provideRetrofit(
okHttpClient: OkHttpClient,
json: Json,
appInfo: AppInfo
): Retrofit = Retrofit.Builder()
.baseUrl(appInfo.messagesBaseUrl)
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
@Singleton
@Provides
fun provideAdminMessageService(retrofit: Retrofit): AdminMessageService = retrofit.create()
@Singleton @Singleton
@Provides @Provides
@ -67,14 +105,6 @@ internal class RepositoryModule {
appInfo: AppInfo appInfo: AppInfo
) = AppDatabase.newInstance(context, sharedPrefProvider, appInfo) ) = AppDatabase.newInstance(context, sharedPrefProvider, appInfo)
@Singleton
@Provides
fun provideResources(@ApplicationContext context: Context): Resources = context.resources
@Singleton
@Provides
fun provideAssets(@ApplicationContext context: Context): AssetManager = context.assets
@Singleton @Singleton
@Provides @Provides
fun provideSharedPref(@ApplicationContext context: Context): SharedPreferences = fun provideSharedPref(@ApplicationContext context: Context): SharedPreferences =
@ -88,7 +118,9 @@ internal class RepositoryModule {
@Singleton @Singleton
@Provides @Provides
fun provideMoshi() = Moshi.Builder().build() fun provideJson() = Json {
ignoreUnknownKeys = true
}
@Singleton @Singleton
@Provides @Provides
@ -202,4 +234,12 @@ internal class RepositoryModule {
@Singleton @Singleton
@Provides @Provides
fun provideSchoolAnnouncementDao(database: AppDatabase) = database.schoolAnnouncementDao fun provideSchoolAnnouncementDao(database: AppDatabase) = database.schoolAnnouncementDao
@Singleton
@Provides
fun provideNotificationDao(database: AppDatabase) = database.notificationDao
@Singleton
@Provides
fun provideAdminMessageDao(database: AppDatabase) = database.adminMessagesDao
} }

View File

@ -0,0 +1,12 @@
package io.github.wulkanowy.data.api
import io.github.wulkanowy.data.db.entities.AdminMessage
import retrofit2.http.GET
import javax.inject.Singleton
@Singleton
interface AdminMessageService {
@GET("/v1.json")
suspend fun getAdminMessages(): List<AdminMessage>
}

View File

@ -6,11 +6,11 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.RoomDatabase.JournalMode.TRUNCATE import androidx.room.RoomDatabase.JournalMode.TRUNCATE
import androidx.room.TypeConverters import androidx.room.TypeConverters
import io.github.wulkanowy.data.db.dao.AdminMessageDao
import io.github.wulkanowy.data.db.dao.AttendanceDao import io.github.wulkanowy.data.db.dao.AttendanceDao
import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao import io.github.wulkanowy.data.db.dao.AttendanceSummaryDao
import io.github.wulkanowy.data.db.dao.CompletedLessonsDao import io.github.wulkanowy.data.db.dao.CompletedLessonsDao
import io.github.wulkanowy.data.db.dao.ConferenceDao import io.github.wulkanowy.data.db.dao.ConferenceDao
import io.github.wulkanowy.data.db.dao.SchoolAnnouncementDao
import io.github.wulkanowy.data.db.dao.ExamDao import io.github.wulkanowy.data.db.dao.ExamDao
import io.github.wulkanowy.data.db.dao.GradeDao import io.github.wulkanowy.data.db.dao.GradeDao
import io.github.wulkanowy.data.db.dao.GradePartialStatisticsDao import io.github.wulkanowy.data.db.dao.GradePartialStatisticsDao
@ -23,8 +23,10 @@ import io.github.wulkanowy.data.db.dao.MessageAttachmentDao
import io.github.wulkanowy.data.db.dao.MessagesDao import io.github.wulkanowy.data.db.dao.MessagesDao
import io.github.wulkanowy.data.db.dao.MobileDeviceDao import io.github.wulkanowy.data.db.dao.MobileDeviceDao
import io.github.wulkanowy.data.db.dao.NoteDao import io.github.wulkanowy.data.db.dao.NoteDao
import io.github.wulkanowy.data.db.dao.NotificationDao
import io.github.wulkanowy.data.db.dao.RecipientDao import io.github.wulkanowy.data.db.dao.RecipientDao
import io.github.wulkanowy.data.db.dao.ReportingUnitDao import io.github.wulkanowy.data.db.dao.ReportingUnitDao
import io.github.wulkanowy.data.db.dao.SchoolAnnouncementDao
import io.github.wulkanowy.data.db.dao.SchoolDao import io.github.wulkanowy.data.db.dao.SchoolDao
import io.github.wulkanowy.data.db.dao.SemesterDao import io.github.wulkanowy.data.db.dao.SemesterDao
import io.github.wulkanowy.data.db.dao.StudentDao import io.github.wulkanowy.data.db.dao.StudentDao
@ -34,11 +36,11 @@ import io.github.wulkanowy.data.db.dao.TeacherDao
import io.github.wulkanowy.data.db.dao.TimetableAdditionalDao import io.github.wulkanowy.data.db.dao.TimetableAdditionalDao
import io.github.wulkanowy.data.db.dao.TimetableDao import io.github.wulkanowy.data.db.dao.TimetableDao
import io.github.wulkanowy.data.db.dao.TimetableHeaderDao import io.github.wulkanowy.data.db.dao.TimetableHeaderDao
import io.github.wulkanowy.data.db.entities.AdminMessage
import io.github.wulkanowy.data.db.entities.Attendance import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.db.entities.AttendanceSummary import io.github.wulkanowy.data.db.entities.AttendanceSummary
import io.github.wulkanowy.data.db.entities.CompletedLesson import io.github.wulkanowy.data.db.entities.CompletedLesson
import io.github.wulkanowy.data.db.entities.Conference import io.github.wulkanowy.data.db.entities.Conference
import io.github.wulkanowy.data.db.entities.SchoolAnnouncement
import io.github.wulkanowy.data.db.entities.Exam import io.github.wulkanowy.data.db.entities.Exam
import io.github.wulkanowy.data.db.entities.Grade import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradePartialStatistics import io.github.wulkanowy.data.db.entities.GradePartialStatistics
@ -51,9 +53,11 @@ import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.db.entities.MessageAttachment import io.github.wulkanowy.data.db.entities.MessageAttachment
import io.github.wulkanowy.data.db.entities.MobileDevice import io.github.wulkanowy.data.db.entities.MobileDevice
import io.github.wulkanowy.data.db.entities.Note import io.github.wulkanowy.data.db.entities.Note
import io.github.wulkanowy.data.db.entities.Notification
import io.github.wulkanowy.data.db.entities.Recipient import io.github.wulkanowy.data.db.entities.Recipient
import io.github.wulkanowy.data.db.entities.ReportingUnit import io.github.wulkanowy.data.db.entities.ReportingUnit
import io.github.wulkanowy.data.db.entities.School import io.github.wulkanowy.data.db.entities.School
import io.github.wulkanowy.data.db.entities.SchoolAnnouncement
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.StudentInfo import io.github.wulkanowy.data.db.entities.StudentInfo
@ -95,6 +99,11 @@ import io.github.wulkanowy.data.db.migrations.Migration37
import io.github.wulkanowy.data.db.migrations.Migration38 import io.github.wulkanowy.data.db.migrations.Migration38
import io.github.wulkanowy.data.db.migrations.Migration39 import io.github.wulkanowy.data.db.migrations.Migration39
import io.github.wulkanowy.data.db.migrations.Migration4 import io.github.wulkanowy.data.db.migrations.Migration4
import io.github.wulkanowy.data.db.migrations.Migration40
import io.github.wulkanowy.data.db.migrations.Migration41
import io.github.wulkanowy.data.db.migrations.Migration42
import io.github.wulkanowy.data.db.migrations.Migration43
import io.github.wulkanowy.data.db.migrations.Migration44
import io.github.wulkanowy.data.db.migrations.Migration5 import io.github.wulkanowy.data.db.migrations.Migration5
import io.github.wulkanowy.data.db.migrations.Migration6 import io.github.wulkanowy.data.db.migrations.Migration6
import io.github.wulkanowy.data.db.migrations.Migration7 import io.github.wulkanowy.data.db.migrations.Migration7
@ -134,6 +143,8 @@ import javax.inject.Singleton
StudentInfo::class, StudentInfo::class,
TimetableHeader::class, TimetableHeader::class,
SchoolAnnouncement::class, SchoolAnnouncement::class,
Notification::class,
AdminMessage::class
], ],
version = AppDatabase.VERSION_SCHEMA, version = AppDatabase.VERSION_SCHEMA,
exportSchema = true exportSchema = true
@ -142,7 +153,7 @@ import javax.inject.Singleton
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
companion object { companion object {
const val VERSION_SCHEMA = 39 const val VERSION_SCHEMA = 44
fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf( fun getMigrations(sharedPrefProvider: SharedPrefProvider, appInfo: AppInfo) = arrayOf(
Migration2(), Migration2(),
@ -183,6 +194,11 @@ abstract class AppDatabase : RoomDatabase() {
Migration37(), Migration37(),
Migration38(), Migration38(),
Migration39(), Migration39(),
Migration40(),
Migration41(sharedPrefProvider),
Migration42(),
Migration43(),
Migration44()
) )
fun newInstance( fun newInstance(
@ -252,4 +268,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract val timetableHeaderDao: TimetableHeaderDao abstract val timetableHeaderDao: TimetableHeaderDao
abstract val schoolAnnouncementDao: SchoolAnnouncementDao abstract val schoolAnnouncementDao: SchoolAnnouncementDao
abstract val notificationDao: NotificationDao
abstract val adminMessagesDao: AdminMessageDao
} }

View File

@ -1,9 +1,10 @@
package io.github.wulkanowy.data.db package io.github.wulkanowy.data.db
import androidx.room.TypeConverter import androidx.room.TypeConverter
import com.squareup.moshi.Moshi import kotlinx.serialization.SerializationException
import com.squareup.moshi.Types import kotlinx.serialization.decodeFromString
import io.github.wulkanowy.data.db.adapters.PairAdapterFactory import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
@ -13,15 +14,7 @@ import java.util.Date
class Converters { class Converters {
private val moshi by lazy { Moshi.Builder().add(PairAdapterFactory).build() } private val json = Json
private val integerListAdapter by lazy {
moshi.adapter<List<Int>>(Types.newParameterizedType(List::class.java, Integer::class.java))
}
private val stringListPairAdapter by lazy {
moshi.adapter<List<Pair<String, String>>>(Types.newParameterizedType(List::class.java, Pair::class.java, String::class.java, String::class.java))
}
@TypeConverter @TypeConverter
fun timestampToDate(value: Long?): LocalDate? = value?.run { fun timestampToDate(value: Long?): LocalDate? = value?.run {
@ -51,21 +44,25 @@ class Converters {
@TypeConverter @TypeConverter
fun intListToJson(list: List<Int>): String { fun intListToJson(list: List<Int>): String {
return integerListAdapter.toJson(list) return json.encodeToString(list)
} }
@TypeConverter @TypeConverter
fun jsonToIntList(value: String): List<Int> { fun jsonToIntList(value: String): List<Int> {
return integerListAdapter.fromJson(value).orEmpty() return json.decodeFromString(value)
} }
@TypeConverter @TypeConverter
fun stringPairListToJson(list: List<Pair<String, String>>): String { fun stringPairListToJson(list: List<Pair<String, String>>): String {
return stringListPairAdapter.toJson(list) return json.encodeToString(list)
} }
@TypeConverter @TypeConverter
fun jsonToStringPairList(value: String): List<Pair<String, String>> { fun jsonToStringPairList(value: String): List<Pair<String, String>> {
return stringListPairAdapter.fromJson(value).orEmpty() return try {
json.decodeFromString(value)
} catch (e: SerializationException) {
emptyList() // handle errors from old gson Pair serialized data
}
} }
} }

View File

@ -22,11 +22,14 @@ class SharedPrefProvider @Inject constructor(
fun getString(key: String) = sharedPref.getString(key, null) fun getString(key: String) = sharedPref.getString(key, null)
fun getString(key: String, defaultValue: String): String = sharedPref.getString(key, defaultValue) ?: defaultValue fun getString(key: String, defaultValue: String): String =
sharedPref.getString(key, defaultValue) ?: defaultValue
fun getBoolean(key: String, defaultValue: Boolean): Boolean = sharedPref.getBoolean(key, defaultValue) fun getBoolean(key: String, defaultValue: Boolean): Boolean =
sharedPref.getBoolean(key, defaultValue)
fun putBoolean(key: String, value: Boolean, sync: Boolean = false) = sharedPref.edit(sync) { putBoolean(key, value) } fun putBoolean(key: String, value: Boolean, sync: Boolean = false) =
sharedPref.edit(sync) { putBoolean(key, value) }
fun putString(key: String, value: String?, sync: Boolean = false) { fun putString(key: String, value: String?, sync: Boolean = false) {
sharedPref.edit(sync) { putString(key, value) } sharedPref.edit(sync) { putString(key, value) }

View File

@ -1,68 +0,0 @@
package io.github.wulkanowy.data.db.adapters
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import java.lang.reflect.ParameterizedType
import java.lang.reflect.Type
object PairAdapterFactory : JsonAdapter.Factory {
override fun create(type: Type, annotations: MutableSet<out Annotation>, moshi: Moshi): JsonAdapter<*>? {
if (type !is ParameterizedType || List::class.java != type.rawType) return null
if (type.actualTypeArguments[0] != Pair::class.java) return null
val listType = Types.newParameterizedType(List::class.java, Map::class.java, String::class.java)
val listAdapter = moshi.adapter<List<Map<String, String>>>(listType)
val mapType = Types.newParameterizedType(MutableMap::class.java, String::class.java, String::class.java)
val mapAdapter = moshi.adapter<Map<String, String>>(mapType)
return PairAdapter(listAdapter, mapAdapter)
}
private class PairAdapter(
private val listAdapter: JsonAdapter<List<Map<String, String>>>,
private val mapAdapter: JsonAdapter<Map<String, String>>,
) : JsonAdapter<List<Pair<String, String>>>() {
override fun toJson(writer: JsonWriter, value: List<Pair<String, String>>?) {
writer.beginArray()
value?.forEach {
writer.beginObject()
writer.name("first").value(it.first)
writer.name("second").value(it.second)
writer.endObject()
}
writer.endArray()
}
override fun fromJson(reader: JsonReader): List<Pair<String, String>>? {
return if (reader.peek() == JsonReader.Token.BEGIN_OBJECT) deserializeMoshiMap(reader)
else deserializeGsonPair(reader)
}
// for compatibility with 0.21.0
private fun deserializeMoshiMap(reader: JsonReader): List<Pair<String, String>>? {
val map = mapAdapter.fromJson(reader) ?: return null
return map.entries.map {
it.key to it.value
}
}
private fun deserializeGsonPair(reader: JsonReader): List<Pair<String, String>>? {
val list = listAdapter.fromJson(reader) ?: return null
return list.map {
require(it.size == 2) {
"pair with more or less than two elements: $list"
}
it["first"].orEmpty() to it["second"].orEmpty()
}
}
}
}

View File

@ -0,0 +1,25 @@
package io.github.wulkanowy.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import io.github.wulkanowy.data.db.entities.AdminMessage
import kotlinx.coroutines.flow.Flow
import javax.inject.Singleton
@Singleton
@Dao
abstract class AdminMessageDao : BaseDao<AdminMessage> {
@Query("SELECT * FROM AdminMessages")
abstract fun loadAll(): Flow<List<AdminMessage>>
@Transaction
open suspend fun removeOldAndSaveNew(
oldMessages: List<AdminMessage>,
newMessages: List<AdminMessage>
) {
deleteAll(oldMessages)
insertAll(newMessages)
}
}

View File

@ -11,6 +11,11 @@ import javax.inject.Singleton
@Dao @Dao
interface AttendanceDao : BaseDao<Attendance> { interface AttendanceDao : BaseDao<Attendance> {
@Query("SELECT * FROM Attendance WHERE diary_id = :diaryId AND student_id = :studentId AND date >= :from AND date <= :end") @Query("SELECT * FROM Attendance WHERE diary_id = :diaryId AND student_id = :studentId AND date >= :start AND date <= :end")
fun loadAll(diaryId: Int, studentId: Int, from: LocalDate, end: LocalDate): Flow<List<Attendance>> fun loadAll(
diaryId: Int,
studentId: Int,
start: LocalDate,
end: LocalDate
): Flow<List<Attendance>>
} }

View File

@ -0,0 +1,15 @@
package io.github.wulkanowy.data.db.dao
import androidx.room.Dao
import androidx.room.Query
import io.github.wulkanowy.data.db.entities.Notification
import kotlinx.coroutines.flow.Flow
import javax.inject.Singleton
@Singleton
@Dao
interface NotificationDao : BaseDao<Notification> {
@Query("SELECT * FROM Notifications WHERE student_id = :studentId OR student_id = -1")
fun loadAll(studentId: Long): Flow<List<Notification>>
}

View File

@ -14,33 +14,39 @@ import javax.inject.Singleton
@Singleton @Singleton
@Dao @Dao
interface StudentDao { abstract class StudentDao {
@Insert(onConflict = ABORT) @Insert(onConflict = ABORT)
suspend fun insertAll(student: List<Student>): List<Long> abstract suspend fun insertAll(student: List<Student>): List<Long>
@Delete @Delete
suspend fun delete(student: Student) abstract suspend fun delete(student: Student)
@Update(entity = Student::class) @Update(entity = Student::class)
suspend fun update(studentNickAndAvatar: StudentNickAndAvatar) abstract suspend fun update(studentNickAndAvatar: StudentNickAndAvatar)
@Query("SELECT * FROM Students WHERE is_current = 1") @Query("SELECT * FROM Students WHERE is_current = 1")
suspend fun loadCurrent(): Student? abstract suspend fun loadCurrent(): Student?
@Query("SELECT * FROM Students WHERE id = :id") @Query("SELECT * FROM Students WHERE id = :id")
suspend fun loadById(id: Long): Student? abstract suspend fun loadById(id: Long): Student?
@Query("SELECT * FROM Students") @Query("SELECT * FROM Students")
suspend fun loadAll(): List<Student> abstract suspend fun loadAll(): List<Student>
@Transaction @Transaction
@Query("SELECT * FROM Students") @Query("SELECT * FROM Students")
suspend fun loadStudentsWithSemesters(): List<StudentWithSemesters> abstract suspend fun loadStudentsWithSemesters(): List<StudentWithSemesters>
@Query("UPDATE Students SET is_current = 1 WHERE id = :id") @Query("UPDATE Students SET is_current = 1 WHERE id = :id")
suspend fun updateCurrent(id: Long) abstract suspend fun updateCurrent(id: Long)
@Query("UPDATE Students SET is_current = 0") @Query("UPDATE Students SET is_current = 0")
suspend fun resetCurrent() abstract suspend fun resetCurrent()
@Transaction
open suspend fun switchCurrent(id: Long) {
resetCurrent()
updateCurrent(id)
}
} }

View File

@ -0,0 +1,40 @@
package io.github.wulkanowy.data.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.Serializable
@Serializable
@Entity(tableName = "AdminMessages")
data class AdminMessage(
@PrimaryKey
val id: Int,
val title: String,
val content: String,
@ColumnInfo(name = "version_name")
val versionMin: Int? = null,
@ColumnInfo(name = "version_max")
val versionMax: Int? = null,
@ColumnInfo(name = "target_register_host")
val targetRegisterHost: String? = null,
@ColumnInfo(name = "target_flavor")
val targetFlavor: String? = null,
@ColumnInfo(name = "destination_url")
val destinationUrl: String? = null,
val priority: String,
val type: String,
@ColumnInfo(name = "is_dismissible")
val isDismissible: Boolean = false
)

View File

@ -47,4 +47,7 @@ data class Attendance(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
var id: Long = 0 var id: Long = 0
@ColumnInfo(name = "is_notified")
var isNotified: Boolean = true
} }

View File

@ -40,4 +40,7 @@ data class Homework(
@ColumnInfo(name = "is_notified") @ColumnInfo(name = "is_notified")
var isNotified: Boolean = true var isNotified: Boolean = true
@ColumnInfo(name = "is_added_by_user")
var isAddedByUser: Boolean = false
} }

View File

@ -0,0 +1,27 @@
package io.github.wulkanowy.data.db.entities
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import io.github.wulkanowy.services.sync.notifications.NotificationType
import java.time.LocalDateTime
@Entity(tableName = "Notifications")
data class Notification(
@ColumnInfo(name = "student_id")
val studentId: Long,
val title: String,
val content: String,
val type: NotificationType,
val date: LocalDateTime,
val data: String? = null
) {
@PrimaryKey(autoGenerate = true)
var id: Long = 0
}

View File

@ -3,10 +3,9 @@ package io.github.wulkanowy.data.db.entities
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.squareup.moshi.JsonClass
import java.io.Serializable import java.io.Serializable
@JsonClass(generateAdapter = true) @kotlinx.serialization.Serializable
@Entity(tableName = "Recipients") @Entity(tableName = "Recipients")
data class Recipient( data class Recipient(

View File

@ -50,4 +50,7 @@ data class Timetable(
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
var id: Long = 0 var id: Long = 0
@ColumnInfo(name = "is_notified")
var isNotified: Boolean = true
} }

View File

@ -0,0 +1,23 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration40 : Migration(39, 40) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS `Notifications` (
`student_id` INTEGER NOT NULL,
`title` TEXT NOT NULL,
`content` TEXT NOT NULL,
`type` TEXT NOT NULL,
`date` INTEGER NOT NULL,
`data` TEXT,
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL
)
"""
)
}
}

View File

@ -0,0 +1,21 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import io.github.wulkanowy.data.db.SharedPrefProvider
import io.github.wulkanowy.ui.modules.grade.GradeExpandMode
class Migration41(private val sharedPrefProvider: SharedPrefProvider) : Migration(40, 41) {
override fun migrate(database: SupportSQLiteDatabase) {
migrateSharedPreferences()
database.execSQL("ALTER TABLE Homework ADD COLUMN is_added_by_user INTEGER NOT NULL DEFAULT 0")
}
private fun migrateSharedPreferences() {
if (sharedPrefProvider.getBoolean("pref_key_expand_grade", false)) {
sharedPrefProvider.putString("pref_key_expand_grade_mode", GradeExpandMode.ALWAYS_EXPANDED.value)
}
sharedPrefProvider.delete("pref_key_expand_grade")
}
}

View File

@ -0,0 +1,24 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration42 : Migration(41, 42) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `AdminMessages` (
`id` INTEGER NOT NULL,
`title` TEXT NOT NULL,
`content` TEXT NOT NULL,
`version_name` INTEGER,
`version_max` INTEGER,
`target_register_host` TEXT,
`target_flavor` TEXT,
`destination_url` TEXT,
`priority` TEXT NOT NULL,
`type` TEXT NOT NULL,
PRIMARY KEY(`id`))"""
)
}
}

View File

@ -0,0 +1,12 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration43 : Migration(42, 43) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE Timetable ADD COLUMN is_notified INTEGER NOT NULL DEFAULT 1")
database.execSQL("ALTER TABLE Attendance ADD COLUMN is_notified INTEGER NOT NULL DEFAULT 1")
}
}

View File

@ -0,0 +1,11 @@
package io.github.wulkanowy.data.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration44 : Migration(43, 44) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE AdminMessages ADD COLUMN is_dismissible INTEGER NOT NULL DEFAULT 0")
}
}

View File

@ -1,8 +1,8 @@
package io.github.wulkanowy.data.pojos package io.github.wulkanowy.data.pojos
import com.squareup.moshi.JsonClass import kotlinx.serialization.Serializable
@JsonClass(generateAdapter = true) @Serializable
class Contributor( class Contributor(
val displayName: String, val displayName: String,
val githubUsername: String val githubUsername: String

View File

@ -1,9 +1,9 @@
package io.github.wulkanowy.data.pojos package io.github.wulkanowy.data.pojos
import com.squareup.moshi.JsonClass
import io.github.wulkanowy.ui.modules.message.send.RecipientChipItem import io.github.wulkanowy.ui.modules.message.send.RecipientChipItem
import kotlinx.serialization.Serializable
@JsonClass(generateAdapter = true) @Serializable
data class MessageDraft( data class MessageDraft(
val recipients: List<RecipientChipItem>, val recipients: List<RecipientChipItem>,
val subject: String, val subject: String,

View File

@ -1,36 +0,0 @@
package io.github.wulkanowy.data.pojos
import androidx.annotation.DrawableRes
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import io.github.wulkanowy.services.sync.notifications.NotificationType
import io.github.wulkanowy.ui.modules.main.MainView
sealed interface Notification {
val type: NotificationType
val startMenu: MainView.Section
val icon: Int
val titleStringRes: Int
val contentStringRes: Int
}
data class MultipleNotifications(
override val type: NotificationType,
override val startMenu: MainView.Section,
@DrawableRes override val icon: Int,
@PluralsRes override val titleStringRes: Int,
@PluralsRes override val contentStringRes: Int,
@PluralsRes val summaryStringRes: Int,
val lines: List<String>,
) : Notification
data class OneNotification(
override val type: NotificationType,
override val startMenu: MainView.Section,
@DrawableRes override val icon: Int,
@StringRes override val titleStringRes: Int,
@StringRes override val contentStringRes: Int,
val contentValues: List<String>,
) : Notification

View File

@ -0,0 +1,19 @@
package io.github.wulkanowy.data.pojos
import android.content.Intent
import io.github.wulkanowy.services.sync.notifications.NotificationType
data class NotificationData(
val intentToStart: Intent,
val title: String,
val content: String
)
data class GroupNotificationData(
val notificationDataList: List<NotificationData>,
val title: String,
val content: String,
val intentToStart: Intent,
val type: NotificationType
)

View File

@ -0,0 +1,52 @@
package io.github.wulkanowy.data.repositories
import io.github.wulkanowy.data.api.AdminMessageService
import io.github.wulkanowy.data.db.dao.AdminMessageDao
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.utils.AppInfo
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.networkBoundResource
import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AdminMessageRepository @Inject constructor(
private val adminMessageService: AdminMessageService,
private val adminMessageDao: AdminMessageDao,
private val appInfo: AppInfo,
private val refreshHelper: AutoRefreshHelper,
) {
private val saveFetchResultMutex = Mutex()
private val cacheKey = "admin_messages"
suspend fun getAdminMessages(student: Student, forceRefresh: Boolean) = networkBoundResource(
mutex = saveFetchResultMutex,
query = { adminMessageDao.loadAll() },
fetch = { adminMessageService.getAdminMessages() },
shouldFetch = {
refreshHelper.shouldBeRefreshed(cacheKey) || forceRefresh
},
saveFetchResult = { oldItems, newItems ->
adminMessageDao.removeOldAndSaveNew(oldItems, newItems)
refreshHelper.updateLastRefreshTimestamp(cacheKey)
},
showSavedOnLoading = false,
mapResult = { adminMessages ->
adminMessages.filter { adminMessage ->
val isCorrectRegister = adminMessage.targetRegisterHost?.let {
student.scrapperBaseUrl.contains(it, true)
} ?: true
val isCorrectFlavor =
adminMessage.targetFlavor?.equals(appInfo.buildFlavor, true) ?: true
val isCorrectMaxVersion =
adminMessage.versionMax?.let { it >= appInfo.versionCode } ?: true
val isCorrectMinVersion =
adminMessage.versionMin?.let { it <= appInfo.versionCode } ?: true
isCorrectRegister && isCorrectFlavor && isCorrectMaxVersion && isCorrectMinVersion
}.maxByOrNull { it.id }
}
)
}

View File

@ -1,25 +1,27 @@
package io.github.wulkanowy.data.repositories package io.github.wulkanowy.data.repositories
import android.content.res.AssetManager import android.content.Context
import com.squareup.moshi.Moshi import dagger.hilt.android.qualifiers.ApplicationContext
import com.squareup.moshi.Types
import io.github.wulkanowy.data.pojos.Contributor import io.github.wulkanowy.data.pojos.Contributor
import io.github.wulkanowy.utils.DispatchersProvider import io.github.wulkanowy.utils.DispatchersProvider
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class AppCreatorRepository @Inject constructor( class AppCreatorRepository @Inject constructor(
private val assets: AssetManager, @ApplicationContext private val context: Context,
private val dispatchers: DispatchersProvider private val dispatchers: DispatchersProvider,
private val json: Json,
) { ) {
@OptIn(ExperimentalSerializationApi::class)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun getAppCreators() = withContext(dispatchers.backgroundThread) { suspend fun getAppCreators() = withContext(dispatchers.io) {
val moshi = Moshi.Builder().build() val inputStream = context.assets.open("contributors.json").buffered()
val type = Types.newParameterizedType(List::class.java, Contributor::class.java) json.decodeFromStream<List<Contributor>>(inputStream)
val adapter = moshi.adapter<List<Contributor>>(type)
adapter.fromJson(assets.open("contributors.json").bufferedReader().use { it.readText() })
} }
} }

View File

@ -14,6 +14,7 @@ import io.github.wulkanowy.utils.monday
import io.github.wulkanowy.utils.networkBoundResource import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.sunday import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
@ -32,10 +33,24 @@ class AttendanceRepository @Inject constructor(
private val cacheKey = "attendance" private val cacheKey = "attendance"
fun getAttendance(student: Student, semester: Semester, start: LocalDate, end: LocalDate, forceRefresh: Boolean) = networkBoundResource( fun getAttendance(
student: Student,
semester: Semester,
start: LocalDate,
end: LocalDate,
forceRefresh: Boolean,
notify: Boolean = false,
) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { it.isEmpty() || forceRefresh || refreshHelper.isShouldBeRefreshed(getRefreshKey(cacheKey, semester, start, end)) }, shouldFetch = {
query = { attendanceDb.loadAll(semester.diaryId, semester.studentId, start.monday, end.sunday) }, val isExpired = refreshHelper.shouldBeRefreshed(
key = getRefreshKey(cacheKey, semester, start, end)
)
it.isEmpty() || forceRefresh || isExpired
},
query = {
attendanceDb.loadAll(semester.diaryId, semester.studentId, start.monday, end.sunday)
},
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
.getAttendance(start.monday, end.sunday, semester.semesterId) .getAttendance(start.monday, end.sunday, semester.semesterId)
@ -43,19 +58,39 @@ class AttendanceRepository @Inject constructor(
}, },
saveFetchResult = { old, new -> saveFetchResult = { old, new ->
attendanceDb.deleteAll(old uniqueSubtract new) attendanceDb.deleteAll(old uniqueSubtract new)
attendanceDb.insertAll(new uniqueSubtract old) val attendanceToAdd = (new uniqueSubtract old).map { newAttendance ->
newAttendance.apply { if (notify) isNotified = false }
}
attendanceDb.insertAll(attendanceToAdd)
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester, start, end)) refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester, start, end))
}, },
filterResult = { it.filter { item -> item.date in start..end } } filterResult = { it.filter { item -> item.date in start..end } }
) )
suspend fun excuseForAbsence(student: Student, semester: Semester, absenceList: List<Attendance>, reason: String? = null) { fun getAttendanceFromDatabase(
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear).excuseForAbsence(absenceList.map { attendance -> semester: Semester,
start: LocalDate,
end: LocalDate
): Flow<List<Attendance>> {
return attendanceDb.loadAll(semester.diaryId, semester.studentId, start, end)
}
suspend fun updateTimetable(timetable: List<Attendance>) {
return attendanceDb.updateAll(timetable)
}
suspend fun excuseForAbsence(
student: Student, semester: Semester,
absenceList: List<Attendance>, reason: String? = null
) {
val items = absenceList.map { attendance ->
Absent( Absent(
date = LocalDateTime.of(attendance.date, LocalTime.of(0, 0)), date = LocalDateTime.of(attendance.date, LocalTime.of(0, 0)),
timeId = attendance.timeId timeId = attendance.timeId
) )
}, reason) }
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
.excuseForAbsence(items, reason)
} }
} }

View File

@ -29,12 +29,12 @@ class AttendanceSummaryRepository @Inject constructor(
student: Student, student: Student,
semester: Semester, semester: Semester,
subjectId: Int, subjectId: Int,
forceRefresh: Boolean forceRefresh: Boolean,
) = networkBoundResource( ) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { shouldFetch = {
it.isEmpty() || forceRefresh val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, semester))
|| refreshHelper.isShouldBeRefreshed(getRefreshKey(cacheKey, semester)) it.isEmpty() || forceRefresh || isExpired
}, },
query = { attendanceDb.loadAll(semester.diaryId, semester.studentId, subjectId) }, query = { attendanceDb.loadAll(semester.diaryId, semester.studentId, subjectId) },
fetch = { fetch = {

View File

@ -28,10 +28,28 @@ class CompletedLessonsRepository @Inject constructor(
private val cacheKey = "completed" private val cacheKey = "completed"
fun getCompletedLessons(student: Student, semester: Semester, start: LocalDate, end: LocalDate, forceRefresh: Boolean) = networkBoundResource( fun getCompletedLessons(
student: Student,
semester: Semester,
start: LocalDate,
end: LocalDate,
forceRefresh: Boolean,
) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { it.isEmpty() || forceRefresh || refreshHelper.isShouldBeRefreshed(getRefreshKey(cacheKey, semester, start, end)) }, shouldFetch = {
query = { completedLessonsDb.loadAll(semester.studentId, semester.diaryId, start.monday, end.sunday) }, val isExpired = refreshHelper.shouldBeRefreshed(
key = getRefreshKey(cacheKey, semester, start, end)
)
it.isEmpty() || forceRefresh || isExpired
},
query = {
completedLessonsDb.loadAll(
studentId = semester.studentId,
diaryId = semester.diaryId,
from = start.monday,
end = end.sunday
)
},
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
.getCompletedLessons(start.monday, end.sunday) .getCompletedLessons(start.monday, end.sunday)

View File

@ -35,12 +35,12 @@ class ConferenceRepository @Inject constructor(
semester: Semester, semester: Semester,
forceRefresh: Boolean, forceRefresh: Boolean,
notify: Boolean = false, notify: Boolean = false,
startDate: LocalDateTime = LocalDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC) startDate: LocalDateTime = LocalDateTime.ofInstant(Instant.EPOCH, ZoneOffset.UTC),
) = networkBoundResource( ) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { shouldFetch = {
it.isEmpty() || forceRefresh val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, semester))
|| refreshHelper.isShouldBeRefreshed(getRefreshKey(cacheKey, semester)) it.isEmpty() || forceRefresh || isExpired
}, },
query = { query = {
conferenceDb.loadAll(semester.diaryId, student.studentId, startDate) conferenceDb.loadAll(semester.diaryId, student.studentId, startDate)

View File

@ -36,14 +36,14 @@ class ExamRepository @Inject constructor(
start: LocalDate, start: LocalDate,
end: LocalDate, end: LocalDate,
forceRefresh: Boolean, forceRefresh: Boolean,
notify: Boolean = false notify: Boolean = false,
) = networkBoundResource( ) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { shouldFetch = {
val isShouldBeRefreshed = refreshHelper.isShouldBeRefreshed( val isExpired = refreshHelper.shouldBeRefreshed(
key = getRefreshKey(cacheKey, semester, start, end) key = getRefreshKey(cacheKey, semester, start, end)
) )
it.isEmpty() || forceRefresh || isShouldBeRefreshed it.isEmpty() || forceRefresh || isExpired
}, },
query = { query = {
examDb.loadAll( examDb.loadAll(

View File

@ -37,13 +37,12 @@ class GradeRepository @Inject constructor(
student: Student, student: Student,
semester: Semester, semester: Semester,
forceRefresh: Boolean, forceRefresh: Boolean,
notify: Boolean = false notify: Boolean = false,
) = networkBoundResource( ) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { (details, summaries) -> shouldFetch = { (details, summaries) ->
val isShouldBeRefreshed = val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, semester))
refreshHelper.isShouldBeRefreshed(getRefreshKey(cacheKey, semester)) details.isEmpty() || summaries.isEmpty() || forceRefresh || isExpired
details.isEmpty() || summaries.isEmpty() || forceRefresh || isShouldBeRefreshed
}, },
query = { query = {
val detailsFlow = gradeDb.loadAll(semester.semesterId, semester.studentId) val detailsFlow = gradeDb.loadAll(semester.semesterId, semester.studentId)
@ -71,8 +70,8 @@ class GradeRepository @Inject constructor(
newDetails: List<Grade>, newDetails: List<Grade>,
notify: Boolean notify: Boolean
) { ) {
val notifyBreakDate = val notifyBreakDate = oldGrades.maxByOrNull {it.date }
oldGrades.maxByOrNull { it.date }?.date ?: student.registrationDate.toLocalDate() ?.date ?: student.registrationDate.toLocalDate()
gradeDb.deleteAll(oldGrades uniqueSubtract newDetails) gradeDb.deleteAll(oldGrades uniqueSubtract newDetails)
gradeDb.insertAll((newDetails uniqueSubtract oldGrades).onEach { gradeDb.insertAll((newDetails uniqueSubtract oldGrades).onEach {
if (it.date >= notifyBreakDate) it.apply { if (it.date >= notifyBreakDate) it.apply {
@ -89,8 +88,7 @@ class GradeRepository @Inject constructor(
) { ) {
gradeSummaryDb.deleteAll(oldSummaries uniqueSubtract newSummary) gradeSummaryDb.deleteAll(oldSummaries uniqueSubtract newSummary)
gradeSummaryDb.insertAll((newSummary uniqueSubtract oldSummaries).onEach { summary -> gradeSummaryDb.insertAll((newSummary uniqueSubtract oldSummaries).onEach { summary ->
val oldSummary = val oldSummary = oldSummaries.find { old -> old.subject == summary.subject }
oldSummaries.find { oldSummary -> oldSummary.subject == summary.subject }
summary.isPredictedGradeNotified = when { summary.isPredictedGradeNotified = when {
summary.predictedGrade.isEmpty() -> true summary.predictedGrade.isEmpty() -> true
notify && oldSummary?.predictedGrade != summary.predictedGrade -> false notify && oldSummary?.predictedGrade != summary.predictedGrade -> false

View File

@ -39,9 +39,19 @@ class GradeStatisticsRepository @Inject constructor(
private val semesterCacheKey = "grade_stats_semester" private val semesterCacheKey = "grade_stats_semester"
private val pointsCacheKey = "grade_stats_points" private val pointsCacheKey = "grade_stats_points"
fun getGradesPartialStatistics(student: Student, semester: Semester, subjectName: String, forceRefresh: Boolean) = networkBoundResource( fun getGradesPartialStatistics(
student: Student,
semester: Semester,
subjectName: String,
forceRefresh: Boolean,
) = networkBoundResource(
mutex = partialMutex, mutex = partialMutex,
shouldFetch = { it.isEmpty() || forceRefresh || refreshHelper.isShouldBeRefreshed(getRefreshKey(partialCacheKey, semester)) }, shouldFetch = {
val isExpired = refreshHelper.shouldBeRefreshed(
key = getRefreshKey(partialCacheKey, semester)
)
it.isEmpty() || forceRefresh || isExpired
},
query = { gradePartialStatisticsDb.loadAll(semester.semesterId, semester.studentId) }, query = { gradePartialStatisticsDb.loadAll(semester.semesterId, semester.studentId) },
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
@ -76,9 +86,19 @@ class GradeStatisticsRepository @Inject constructor(
} }
) )
fun getGradesSemesterStatistics(student: Student, semester: Semester, subjectName: String, forceRefresh: Boolean) = networkBoundResource( fun getGradesSemesterStatistics(
student: Student,
semester: Semester,
subjectName: String,
forceRefresh: Boolean,
) = networkBoundResource(
mutex = semesterMutex, mutex = semesterMutex,
shouldFetch = { it.isEmpty() || forceRefresh || refreshHelper.isShouldBeRefreshed(getRefreshKey(semesterCacheKey, semester)) }, shouldFetch = {
val isExpired = refreshHelper.shouldBeRefreshed(
key = getRefreshKey(semesterCacheKey, semester)
)
it.isEmpty() || forceRefresh || isExpired
},
query = { gradeSemesterStatisticsDb.loadAll(semester.semesterId, semester.studentId) }, query = { gradeSemesterStatisticsDb.loadAll(semester.semesterId, semester.studentId) },
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
@ -94,10 +114,12 @@ class GradeStatisticsRepository @Inject constructor(
val itemsWithAverage = items.map { item -> val itemsWithAverage = items.map { item ->
item.copy().apply { item.copy().apply {
val denominator = item.amounts.sum() val denominator = item.amounts.sum()
average = if (denominator == 0) "" else (item.amounts.mapIndexed { gradeValue, amount -> average = if (denominator == 0) "" else {
(gradeValue + 1) * amount (item.amounts.mapIndexed { gradeValue, amount ->
}.sum().toDouble() / denominator).let { (gradeValue + 1) * amount
"%.2f".format(Locale.FRANCE, it) }.sum().toDouble() / denominator).let {
"%.2f".format(Locale.FRANCE, it)
}
} }
} }
} }
@ -109,7 +131,9 @@ class GradeStatisticsRepository @Inject constructor(
amounts = itemsWithAverage.map { it.amounts }.sumGradeAmounts(), amounts = itemsWithAverage.map { it.amounts }.sumGradeAmounts(),
studentGrade = 0 studentGrade = 0
).apply { ).apply {
average = itemsWithAverage.mapNotNull { it.average.replace(",", ".").toDoubleOrNull() }.average().let { average = itemsWithAverage.mapNotNull {
it.average.replace(",", ".").toDoubleOrNull()
}.average().let {
"%.2f".format(Locale.FRANCE, it) "%.2f".format(Locale.FRANCE, it)
} }
}).reversed() }).reversed()
@ -118,9 +142,17 @@ class GradeStatisticsRepository @Inject constructor(
} }
) )
fun getGradesPointsStatistics(student: Student, semester: Semester, subjectName: String, forceRefresh: Boolean) = networkBoundResource( fun getGradesPointsStatistics(
student: Student,
semester: Semester,
subjectName: String,
forceRefresh: Boolean,
) = networkBoundResource(
mutex = pointsMutex, mutex = pointsMutex,
shouldFetch = { it.isEmpty() || forceRefresh || refreshHelper.isShouldBeRefreshed(getRefreshKey(pointsCacheKey, semester)) }, shouldFetch = {
val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(pointsCacheKey, semester))
it.isEmpty() || forceRefresh || isExpired
},
query = { gradePointsStatisticsDb.loadAll(semester.semesterId, semester.studentId) }, query = { gradePointsStatisticsDb.loadAll(semester.semesterId, semester.studentId) },
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)

View File

@ -30,16 +30,19 @@ class HomeworkRepository @Inject constructor(
private val cacheKey = "homework" private val cacheKey = "homework"
fun getHomework( fun getHomework(
student: Student, semester: Semester, student: Student,
start: LocalDate, end: LocalDate, semester: Semester,
forceRefresh: Boolean, notify: Boolean = false start: LocalDate,
end: LocalDate,
forceRefresh: Boolean,
notify: Boolean = false,
) = networkBoundResource( ) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { shouldFetch = {
val isShouldBeRefreshed = refreshHelper.isShouldBeRefreshed( val isExpired = refreshHelper.shouldBeRefreshed(
key = getRefreshKey(cacheKey, semester, start, end) key = getRefreshKey(cacheKey, semester, start, end)
) )
it.isEmpty() || forceRefresh || isShouldBeRefreshed it.isEmpty() || forceRefresh || isExpired
}, },
query = { query = {
homeworkDb.loadAll( homeworkDb.loadAll(
@ -58,8 +61,9 @@ class HomeworkRepository @Inject constructor(
val homeWorkToSave = (new uniqueSubtract old).onEach { val homeWorkToSave = (new uniqueSubtract old).onEach {
if (notify) it.isNotified = false if (notify) it.isNotified = false
} }
val filteredOld = old.filterNot { it.isAddedByUser }
homeworkDb.deleteAll(old uniqueSubtract new) homeworkDb.deleteAll(filteredOld uniqueSubtract new)
homeworkDb.insertAll(homeWorkToSave) homeworkDb.insertAll(homeWorkToSave)
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester, start, end)) refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester, start, end))
@ -76,4 +80,8 @@ class HomeworkRepository @Inject constructor(
homeworkDb.loadAll(semester.semesterId, semester.studentId, start.monday, end.sunday) homeworkDb.loadAll(semester.semesterId, semester.studentId, start.monday, end.sunday)
suspend fun updateHomework(homework: List<Homework>) = homeworkDb.updateAll(homework) suspend fun updateHomework(homework: List<Homework>) = homeworkDb.updateAll(homework)
suspend fun saveHomework(homework: Homework) = homeworkDb.insertAll(listOf(homework))
suspend fun deleteHomework(homework: Homework) = homeworkDb.deleteAll(listOf(homework))
} }

View File

@ -15,24 +15,23 @@ class LoggerRepository @Inject constructor(
suspend fun getLastLogLines() = getLastModified().readText().split("\n") suspend fun getLastLogLines() = getLastModified().readText().split("\n")
suspend fun getLogFiles() = withContext(dispatchers.backgroundThread) { suspend fun getLogFiles() = withContext(dispatchers.io) {
File(context.filesDir.absolutePath).listFiles(File::isFile)?.filter { File(context.filesDir.absolutePath).listFiles(File::isFile)
it.name.endsWith(".log") ?.filter { it.name.endsWith(".log") }!!
}!!
} }
private suspend fun getLastModified(): File { private suspend fun getLastModified() = withContext(dispatchers.io) {
return withContext(dispatchers.backgroundThread) { var lastModifiedTime = Long.MIN_VALUE
var lastModifiedTime = Long.MIN_VALUE var chosenFile: File? = null
var chosenFile: File? = null
File(context.filesDir.absolutePath).listFiles(File::isFile)?.forEach { file -> File(context.filesDir.absolutePath).listFiles(File::isFile)
?.forEach { file ->
if (file.lastModified() > lastModifiedTime) { if (file.lastModified() > lastModifiedTime) {
lastModifiedTime = file.lastModified() lastModifiedTime = file.lastModified()
chosenFile = file chosenFile = file
} }
} }
if (chosenFile == null) throw FileNotFoundException("Log file not found")
chosenFile!! chosenFile ?: throw FileNotFoundException("Log file not found")
}
} }
} }

View File

@ -23,11 +23,17 @@ class LuckyNumberRepository @Inject constructor(
private val saveFetchResultMutex = Mutex() private val saveFetchResultMutex = Mutex()
fun getLuckyNumber(student: Student, forceRefresh: Boolean, notify: Boolean = false) = networkBoundResource( fun getLuckyNumber(
student: Student,
forceRefresh: Boolean,
notify: Boolean = false,
) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { it == null || forceRefresh }, shouldFetch = { it == null || forceRefresh },
query = { luckyNumberDb.load(student.studentId, now()) }, query = { luckyNumberDb.load(student.studentId, now()) },
fetch = { sdk.init(student).getLuckyNumber(student.schoolShortName)?.mapToEntity(student) }, fetch = {
sdk.init(student).getLuckyNumber(student.schoolShortName)?.mapToEntity(student)
},
saveFetchResult = { old, new -> saveFetchResult = { old, new ->
if (new != old) { if (new != old) {
old?.let { luckyNumberDb.deleteAll(listOfNotNull(it)) } old?.let { luckyNumberDb.deleteAll(listOfNotNull(it)) }
@ -41,9 +47,11 @@ class LuckyNumberRepository @Inject constructor(
fun getLuckyNumberHistory(student: Student, start: LocalDate, end: LocalDate) = fun getLuckyNumberHistory(student: Student, start: LocalDate, end: LocalDate) =
luckyNumberDb.getAll(student.studentId, start, end) luckyNumberDb.getAll(student.studentId, start, end)
suspend fun getNotNotifiedLuckyNumber(student: Student) = luckyNumberDb.load(student.studentId, now()).map { suspend fun getNotNotifiedLuckyNumber(student: Student) =
if (it?.isNotified == false) it else null luckyNumberDb.load(student.studentId, now()).map {
}.first() if (it?.isNotified == false) it else null
}.first()
suspend fun updateLuckyNumber(luckyNumber: LuckyNumber?) = luckyNumberDb.updateAll(listOfNotNull(luckyNumber)) suspend fun updateLuckyNumber(luckyNumber: LuckyNumber?) =
luckyNumberDb.updateAll(listOfNotNull(luckyNumber))
} }

View File

@ -1,7 +1,6 @@
package io.github.wulkanowy.data.repositories package io.github.wulkanowy.data.repositories
import android.content.Context import android.content.Context
import com.squareup.moshi.Moshi
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.Resource import io.github.wulkanowy.data.Resource
@ -18,7 +17,6 @@ import io.github.wulkanowy.data.enums.MessageFolder.RECEIVED
import io.github.wulkanowy.data.mappers.mapFromEntities import io.github.wulkanowy.data.mappers.mapFromEntities
import io.github.wulkanowy.data.mappers.mapToEntities import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.data.pojos.MessageDraft import io.github.wulkanowy.data.pojos.MessageDraft
import io.github.wulkanowy.data.pojos.MessageDraftJsonAdapter
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.sdk.pojo.Folder import io.github.wulkanowy.sdk.pojo.Folder
import io.github.wulkanowy.sdk.pojo.SentMessage import io.github.wulkanowy.sdk.pojo.SentMessage
@ -29,6 +27,9 @@ import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime.now import java.time.LocalDateTime.now
import javax.inject.Inject import javax.inject.Inject
@ -42,7 +43,7 @@ class MessageRepository @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val refreshHelper: AutoRefreshHelper, private val refreshHelper: AutoRefreshHelper,
private val sharedPrefProvider: SharedPrefProvider, private val sharedPrefProvider: SharedPrefProvider,
private val moshi: Moshi, private val json: Json,
) { ) {
private val saveFetchResultMutex = Mutex() private val saveFetchResultMutex = Mutex()
@ -51,14 +52,18 @@ class MessageRepository @Inject constructor(
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
fun getMessages( fun getMessages(
student: Student, semester: Semester, student: Student,
folder: MessageFolder, forceRefresh: Boolean, notify: Boolean = false semester: Semester,
folder: MessageFolder,
forceRefresh: Boolean,
notify: Boolean = false,
): Flow<Resource<List<Message>>> = networkBoundResource( ): Flow<Resource<List<Message>>> = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { shouldFetch = {
it.isEmpty() || forceRefresh || refreshHelper.isShouldBeRefreshed( val isExpired = refreshHelper.shouldBeRefreshed(
getRefreshKey(cacheKey, student, folder) key = getRefreshKey(cacheKey, student, folder)
) )
it.isEmpty() || forceRefresh || isExpired
}, },
query = { messagesDb.loadAll(student.id.toInt(), folder.id) }, query = { messagesDb.loadAll(student.id.toInt(), folder.id) },
fetch = { fetch = {
@ -77,7 +82,8 @@ class MessageRepository @Inject constructor(
) )
private fun getMessagesWithReadByChange( private fun getMessagesWithReadByChange(
old: List<Message>, new: List<Message>, old: List<Message>,
new: List<Message>,
setNotified: Boolean setNotified: Boolean
): List<Message> { ): List<Message> {
val oldMeta = old.map { Triple(it, it.readBy, it.unreadBy) } val oldMeta = old.map { Triple(it, it.readBy, it.unreadBy) }
@ -96,7 +102,9 @@ class MessageRepository @Inject constructor(
} }
fun getMessage( fun getMessage(
student: Student, message: Message, markAsRead: Boolean = false student: Student,
message: Message,
markAsRead: Boolean = false,
): Flow<Resource<MessageWithAttachment?>> = networkBoundResource( ): Flow<Resource<MessageWithAttachment?>> = networkBoundResource(
shouldFetch = { shouldFetch = {
checkNotNull(it, { "This message no longer exist!" }) checkNotNull(it, { "This message no longer exist!" })
@ -135,8 +143,10 @@ class MessageRepository @Inject constructor(
} }
suspend fun sendMessage( suspend fun sendMessage(
student: Student, subject: String, content: String, student: Student,
recipients: List<Recipient> subject: String,
content: String,
recipients: List<Recipient>,
): SentMessage = sdk.init(student).sendMessage( ): SentMessage = sdk.init(student).sendMessage(
subject = subject, subject = subject,
content = content, content = content,
@ -159,9 +169,9 @@ class MessageRepository @Inject constructor(
var draftMessage: MessageDraft? var draftMessage: MessageDraft?
get() = sharedPrefProvider.getString(context.getString(R.string.pref_key_message_send_draft)) get() = sharedPrefProvider.getString(context.getString(R.string.pref_key_message_send_draft))
?.let { MessageDraftJsonAdapter(moshi).fromJson(it) } ?.let { json.decodeFromString(it) }
set(value) = sharedPrefProvider.putString( set(value) = sharedPrefProvider.putString(
context.getString(R.string.pref_key_message_send_draft), context.getString(R.string.pref_key_message_send_draft),
value?.let { MessageDraftJsonAdapter(moshi).toJson(it) } value?.let { json.encodeToString(it) }
) )
} }

View File

@ -28,9 +28,16 @@ class MobileDeviceRepository @Inject constructor(
private val cacheKey = "devices" private val cacheKey = "devices"
fun getDevices(student: Student, semester: Semester, forceRefresh: Boolean) = networkBoundResource( fun getDevices(
student: Student,
semester: Semester,
forceRefresh: Boolean,
) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { it.isEmpty() || forceRefresh || refreshHelper.isShouldBeRefreshed(getRefreshKey(cacheKey, student)) }, shouldFetch = {
val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, student))
it.isEmpty() || forceRefresh || isExpired
},
query = { mobileDb.loadAll(student.userLoginId.takeIf { it != 0 } ?: student.studentId) }, query = { mobileDb.loadAll(student.userLoginId.takeIf { it != 0 } ?: student.studentId) },
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)

View File

@ -12,7 +12,6 @@ import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.networkBoundResource import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -28,9 +27,19 @@ class NoteRepository @Inject constructor(
private val cacheKey = "note" private val cacheKey = "note"
fun getNotes(student: Student, semester: Semester, forceRefresh: Boolean, notify: Boolean = false) = networkBoundResource( fun getNotes(
student: Student,
semester: Semester,
forceRefresh: Boolean,
notify: Boolean = false,
) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { it.isEmpty() || forceRefresh || refreshHelper.isShouldBeRefreshed(getRefreshKey(cacheKey, semester)) }, shouldFetch = {
val isExpired = refreshHelper.shouldBeRefreshed(
getRefreshKey(cacheKey, semester)
)
it.isEmpty() || forceRefresh || isExpired
},
query = { noteDb.loadAll(student.studentId) }, query = { noteDb.loadAll(student.studentId) },
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)

View File

@ -0,0 +1,17 @@
package io.github.wulkanowy.data.repositories
import io.github.wulkanowy.data.db.dao.NotificationDao
import io.github.wulkanowy.data.db.entities.Notification
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NotificationRepository @Inject constructor(
private val notificationDao: NotificationDao,
) {
fun getNotifications(studentId: Long) = notificationDao.loadAll(studentId)
suspend fun saveNotification(notification: Notification) =
notificationDao.insertAll(listOf(notification))
}

View File

@ -5,21 +5,21 @@ import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import com.fredporciuncula.flow.preferences.FlowSharedPreferences import com.fredporciuncula.flow.preferences.FlowSharedPreferences
import com.fredporciuncula.flow.preferences.Preference import com.fredporciuncula.flow.preferences.Preference
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.sdk.toLocalDate import io.github.wulkanowy.sdk.toLocalDate
import io.github.wulkanowy.ui.modules.dashboard.DashboardItem import io.github.wulkanowy.ui.modules.dashboard.DashboardItem
import io.github.wulkanowy.ui.modules.grade.GradeAverageMode import io.github.wulkanowy.ui.modules.grade.GradeAverageMode
import io.github.wulkanowy.ui.modules.grade.GradeExpandMode
import io.github.wulkanowy.ui.modules.grade.GradeSortingMode import io.github.wulkanowy.ui.modules.grade.GradeSortingMode
import io.github.wulkanowy.utils.toTimestamp
import io.github.wulkanowy.utils.toLocalDateTime import io.github.wulkanowy.utils.toLocalDateTime
import io.github.wulkanowy.utils.toTimestamp import io.github.wulkanowy.utils.toTimestamp
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.inject.Inject import javax.inject.Inject
@ -28,16 +28,12 @@ import javax.inject.Singleton
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Singleton @Singleton
class PreferencesRepository @Inject constructor( class PreferencesRepository @Inject constructor(
@ApplicationContext val context: Context,
private val sharedPref: SharedPreferences, private val sharedPref: SharedPreferences,
private val flowSharedPref: FlowSharedPreferences, private val flowSharedPref: FlowSharedPreferences,
@ApplicationContext val context: Context, private val json: Json,
moshi: Moshi
) { ) {
@OptIn(ExperimentalStdlibApi::class)
private val dashboardItemsPositionAdapter: JsonAdapter<Map<DashboardItem.Type, Int>> =
moshi.adapter()
val startMenuIndex: Int val startMenuIndex: Int
get() = getString(R.string.pref_key_start_menu, R.string.pref_default_startup).toInt() get() = getString(R.string.pref_key_start_menu, R.string.pref_default_startup).toInt()
@ -61,8 +57,13 @@ class PreferencesRepository @Inject constructor(
R.bool.pref_default_grade_average_force_calc R.bool.pref_default_grade_average_force_calc
) )
val isGradeExpandable: Boolean val gradeExpandMode: GradeExpandMode
get() = !getBoolean(R.string.pref_key_expand_grade, R.bool.pref_default_expand_grade) get() = GradeExpandMode.getByValue(
getString(
R.string.pref_key_expand_grade_mode,
R.string.pref_default_expand_grade_mode
)
)
val showAllSubjectsOnStatisticsList: Boolean val showAllSubjectsOnStatisticsList: Boolean
get() = getBoolean( get() = getBoolean(
@ -102,12 +103,31 @@ class PreferencesRepository @Inject constructor(
val isUpcomingLessonsNotificationsEnableKey = val isUpcomingLessonsNotificationsEnableKey =
context.getString(R.string.pref_key_notifications_upcoming_lessons_enable) context.getString(R.string.pref_key_notifications_upcoming_lessons_enable)
val isUpcomingLessonsNotificationsEnable: Boolean var isUpcomingLessonsNotificationsEnable: Boolean
set(value) {
sharedPref.edit { putBoolean(isUpcomingLessonsNotificationsEnableKey, value) }
}
get() = getBoolean( get() = getBoolean(
isUpcomingLessonsNotificationsEnableKey, isUpcomingLessonsNotificationsEnableKey,
R.bool.pref_default_notification_upcoming_lessons_enable R.bool.pref_default_notification_upcoming_lessons_enable
) )
val isUpcomingLessonsNotificationsPersistentKey =
context.getString(R.string.pref_key_notifications_upcoming_lessons_persistent)
val isUpcomingLessonsNotificationsPersistent: Boolean
get() = getBoolean(
isUpcomingLessonsNotificationsPersistentKey,
R.bool.pref_default_notification_upcoming_lessons_persistent
)
val isNotificationPiggybackEnabledKey =
context.getString(R.string.pref_key_notifications_piggyback)
val isNotificationPiggybackEnabled: Boolean
get() = getBoolean(
R.string.pref_key_notifications_piggyback,
R.bool.pref_default_notification_piggyback
)
val isDebugNotificationEnableKey = context.getString(R.string.pref_key_notification_debug) val isDebugNotificationEnableKey = context.getString(R.string.pref_key_notification_debug)
val isDebugNotificationEnable: Boolean val isDebugNotificationEnable: Boolean
get() = getBoolean(isDebugNotificationEnableKey, R.bool.pref_default_notification_debug) get() = getBoolean(isDebugNotificationEnableKey, R.bool.pref_default_notification_debug)
@ -176,22 +196,20 @@ class PreferencesRepository @Inject constructor(
) )
var lasSyncDate: LocalDateTime var lasSyncDate: LocalDateTime
get() = getLong( get() = getLong(R.string.pref_key_last_sync_date, R.string.pref_default_last_sync_date)
R.string.pref_key_last_sync_date, .toLocalDateTime()
R.string.pref_default_last_sync_date
).toLocalDateTime()
set(value) = sharedPref.edit().putLong("last_sync_date", value.toTimestamp()).apply() set(value) = sharedPref.edit().putLong("last_sync_date", value.toTimestamp()).apply()
var dashboardItemsPosition: Map<DashboardItem.Type, Int>? var dashboardItemsPosition: Map<DashboardItem.Type, Int>?
get() { get() {
val json = sharedPref.getString(PREF_KEY_DASHBOARD_ITEMS_POSITION, null) ?: return null val value = sharedPref.getString(PREF_KEY_DASHBOARD_ITEMS_POSITION, null) ?: return null
return dashboardItemsPositionAdapter.fromJson(json) return json.decodeFromString(value)
} }
set(value) = sharedPref.edit { set(value) = sharedPref.edit {
putString( putString(
PREF_KEY_DASHBOARD_ITEMS_POSITION, PREF_KEY_DASHBOARD_ITEMS_POSITION,
dashboardItemsPositionAdapter.toJson(value) json.encodeToString(value)
) )
} }
@ -200,6 +218,7 @@ class PreferencesRepository @Inject constructor(
.map { set -> .map { set ->
set.map { DashboardItem.Tile.valueOf(it) } set.map { DashboardItem.Tile.valueOf(it) }
.plus(DashboardItem.Tile.ACCOUNT) .plus(DashboardItem.Tile.ACCOUNT)
.plus(DashboardItem.Tile.ADMIN_MESSAGE)
.toSet() .toSet()
} }
@ -207,6 +226,7 @@ class PreferencesRepository @Inject constructor(
get() = selectedDashboardTilesPreference.get() get() = selectedDashboardTilesPreference.get()
.map { DashboardItem.Tile.valueOf(it) } .map { DashboardItem.Tile.valueOf(it) }
.plus(DashboardItem.Tile.ACCOUNT) .plus(DashboardItem.Tile.ACCOUNT)
.plus(DashboardItem.Tile.ADMIN_MESSAGE)
.toSet() .toSet()
set(value) { set(value) {
val filteredValue = value.filterNot { it == DashboardItem.Tile.ACCOUNT } val filteredValue = value.filterNot { it == DashboardItem.Tile.ACCOUNT }
@ -225,13 +245,23 @@ class PreferencesRepository @Inject constructor(
return flowSharedPref.getStringSet(prefKey, defaultSet) return flowSharedPref.getStringSet(prefKey, defaultSet)
} }
var dismissedAdminMessageIds: List<Int>
get() = sharedPref.getStringSet(PREF_KEY_ADMIN_DISMISSED_MESSAGE_IDS, emptySet())
.orEmpty()
.map { it.toInt() }
set(value) = sharedPref.edit {
putStringSet(PREF_KEY_ADMIN_DISMISSED_MESSAGE_IDS, value.map { it.toString() }.toSet())
}
var inAppReviewCount: Int var inAppReviewCount: Int
get() = sharedPref.getInt(PREF_KEY_IN_APP_REVIEW_COUNT, 0) get() = sharedPref.getInt(PREF_KEY_IN_APP_REVIEW_COUNT, 0)
set(value) = sharedPref.edit().putInt(PREF_KEY_IN_APP_REVIEW_COUNT, value).apply() set(value) = sharedPref.edit().putInt(PREF_KEY_IN_APP_REVIEW_COUNT, value).apply()
var inAppReviewDate: LocalDate? var inAppReviewDate: LocalDate?
get() = sharedPref.getLong(PREF_KEY_IN_APP_REVIEW_DATE, 0).takeIf { it != 0L }?.toLocalDate() get() = sharedPref.getLong(PREF_KEY_IN_APP_REVIEW_DATE, 0).takeIf { it != 0L }
set(value) = sharedPref.edit().putLong(PREF_KEY_IN_APP_REVIEW_DATE, value!!.toTimestamp()).apply() ?.toLocalDate()
set(value) = sharedPref.edit().putLong(PREF_KEY_IN_APP_REVIEW_DATE, value!!.toTimestamp())
.apply()
var isAppReviewDone: Boolean var isAppReviewDone: Boolean
get() = sharedPref.getBoolean(PREF_KEY_IN_APP_REVIEW_DONE, false) get() = sharedPref.getBoolean(PREF_KEY_IN_APP_REVIEW_DONE, false)
@ -252,6 +282,9 @@ class PreferencesRepository @Inject constructor(
private fun getBoolean(id: String, default: Int) = private fun getBoolean(id: String, default: Int) =
sharedPref.getBoolean(id, context.resources.getBoolean(default)) sharedPref.getBoolean(id, context.resources.getBoolean(default))
private fun getBoolean(id: Int, default: Boolean) =
sharedPref.getBoolean(context.getString(id), default)
private companion object { private companion object {
private const val PREF_KEY_DASHBOARD_ITEMS_POSITION = "dashboard_items_position" private const val PREF_KEY_DASHBOARD_ITEMS_POSITION = "dashboard_items_position"
@ -261,5 +294,7 @@ class PreferencesRepository @Inject constructor(
private const val PREF_KEY_IN_APP_REVIEW_DATE = "in_app_review_date" private const val PREF_KEY_IN_APP_REVIEW_DATE = "in_app_review_date"
private const val PREF_KEY_IN_APP_REVIEW_DONE = "in_app_review_done" private const val PREF_KEY_IN_APP_REVIEW_DONE = "in_app_review_done"
private const val PREF_KEY_ADMIN_DISMISSED_MESSAGE_IDS = "admin_message_dismissed_ids"
} }
} }

View File

@ -7,6 +7,8 @@ import io.github.wulkanowy.data.db.entities.ReportingUnit
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.mappers.mapToEntities import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import javax.inject.Inject import javax.inject.Inject
@ -15,26 +17,34 @@ import javax.inject.Singleton
@Singleton @Singleton
class RecipientRepository @Inject constructor( class RecipientRepository @Inject constructor(
private val recipientDb: RecipientDao, private val recipientDb: RecipientDao,
private val sdk: Sdk private val sdk: Sdk,
private val refreshHelper: AutoRefreshHelper,
) { ) {
private val cacheKey = "recipient"
suspend fun refreshRecipients(student: Student, unit: ReportingUnit, role: Int) { suspend fun refreshRecipients(student: Student, unit: ReportingUnit, role: Int) {
val new = sdk.init(student).getRecipients(unit.unitId, role).mapToEntities(unit.studentId) val new = sdk.init(student).getRecipients(unit.unitId, role).mapToEntities(unit.studentId)
val old = recipientDb.loadAll(unit.studentId, unit.unitId, role) val old = recipientDb.loadAll(unit.studentId, unit.unitId, role)
recipientDb.deleteAll(old uniqueSubtract new) recipientDb.deleteAll(old uniqueSubtract new)
recipientDb.insertAll(new uniqueSubtract old) recipientDb.insertAll(new uniqueSubtract old)
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, student))
} }
suspend fun getRecipients(student: Student, unit: ReportingUnit, role: Int): List<Recipient> { suspend fun getRecipients(student: Student, unit: ReportingUnit, role: Int): List<Recipient> {
return recipientDb.loadAll(unit.studentId, unit.unitId, role).ifEmpty { val cached = recipientDb.loadAll(unit.studentId, unit.unitId, role)
refreshRecipients(student, unit, role)
val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, student))
return if (cached.isEmpty() || isExpired) {
refreshRecipients(student, unit, role)
recipientDb.loadAll(unit.studentId, unit.unitId, role) recipientDb.loadAll(unit.studentId, unit.unitId, role)
} } else cached
} }
suspend fun getMessageRecipients(student: Student, message: Message): List<Recipient> { suspend fun getMessageRecipients(student: Student, message: Message): List<Recipient> {
return sdk.init(student).getMessageRecipients(message.messageId, message.senderId).mapToEntities(student.studentId) return sdk.init(student).getMessageRecipients(message.messageId, message.senderId)
.mapToEntities(student.studentId)
} }
} }

View File

@ -11,7 +11,7 @@ class RecoverRepository @Inject constructor(private val sdk: Sdk) {
return sdk.getPasswordResetCaptchaCode(host, symbol) return sdk.getPasswordResetCaptchaCode(host, symbol)
} }
suspend fun sendRecoverRequest(url: String, symbol: String, email: String, reCaptchaResponse: String): String { suspend fun sendRecoverRequest(
return sdk.sendPasswordResetRequest(url, symbol, email, reCaptchaResponse) url: String, symbol: String, email: String, reCaptchaResponse: String
} ): String = sdk.sendPasswordResetRequest(url, symbol, email, reCaptchaResponse)
} }

View File

@ -2,7 +2,6 @@ package io.github.wulkanowy.data.repositories
import io.github.wulkanowy.data.db.dao.SchoolAnnouncementDao import io.github.wulkanowy.data.db.dao.SchoolAnnouncementDao
import io.github.wulkanowy.data.db.entities.SchoolAnnouncement import io.github.wulkanowy.data.db.entities.SchoolAnnouncement
import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.mappers.mapToEntities import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
@ -12,7 +11,6 @@ import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.networkBoundResource import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -30,17 +28,15 @@ class SchoolAnnouncementRepository @Inject constructor(
fun getSchoolAnnouncements( fun getSchoolAnnouncements(
student: Student, student: Student,
forceRefresh: Boolean, forceRefresh: Boolean, notify: Boolean = false
notify: Boolean = false
) = networkBoundResource( ) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { shouldFetch = {
it.isEmpty() || forceRefresh val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, student))
|| refreshHelper.isShouldBeRefreshed(getRefreshKey(cacheKey, student)) it.isEmpty() || forceRefresh || isExpired
}, },
query = { query = {
schoolAnnouncementDb.loadAll( schoolAnnouncementDb.loadAll(student.studentId)
student.studentId)
}, },
fetch = { fetch = {
sdk.init(student) sdk.init(student)
@ -57,9 +53,11 @@ class SchoolAnnouncementRepository @Inject constructor(
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, student)) refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, student))
} }
) )
fun getSchoolAnnouncementFromDatabase(student: Student): Flow<List<SchoolAnnouncement>> { fun getSchoolAnnouncementFromDatabase(student: Student): Flow<List<SchoolAnnouncement>> {
return schoolAnnouncementDb.loadAll(student.studentId) return schoolAnnouncementDb.loadAll(student.studentId)
} }
suspend fun updateSchoolAnnouncement(schoolAnnouncement: List<SchoolAnnouncement>) = schoolAnnouncementDb.updateAll(schoolAnnouncement) suspend fun updateSchoolAnnouncement(schoolAnnouncement: List<SchoolAnnouncement>) =
schoolAnnouncementDb.updateAll(schoolAnnouncement)
} }

View File

@ -5,6 +5,8 @@ import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.mappers.mapToEntity import io.github.wulkanowy.data.mappers.mapToEntity
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.networkBoundResource import io.github.wulkanowy.utils.networkBoundResource
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@ -14,29 +16,41 @@ import javax.inject.Singleton
@Singleton @Singleton
class SchoolRepository @Inject constructor( class SchoolRepository @Inject constructor(
private val schoolDb: SchoolDao, private val schoolDb: SchoolDao,
private val sdk: Sdk private val sdk: Sdk,
private val refreshHelper: AutoRefreshHelper,
) { ) {
private val saveFetchResultMutex = Mutex() private val saveFetchResultMutex = Mutex()
fun getSchoolInfo(student: Student, semester: Semester, forceRefresh: Boolean) = private val cacheKey = "school_info"
networkBoundResource(
mutex = saveFetchResultMutex, fun getSchoolInfo(
shouldFetch = { it == null || forceRefresh }, student: Student,
query = { schoolDb.load(semester.studentId, semester.classId) }, semester: Semester,
fetch = { forceRefresh: Boolean,
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear).getSchool() ) = networkBoundResource(
.mapToEntity(semester) mutex = saveFetchResultMutex,
}, shouldFetch = {
saveFetchResult = { old, new -> val isExpired = refreshHelper.shouldBeRefreshed(
if (old != null && new != old) { key = getRefreshKey(cacheKey, student)
with(schoolDb) { )
deleteAll(listOf(old)) it == null || forceRefresh || isExpired
insertAll(listOf(new)) },
} query = { schoolDb.load(semester.studentId, semester.classId) },
} else if (old == null) { fetch = {
schoolDb.insertAll(listOf(new)) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear).getSchool()
.mapToEntity(semester)
},
saveFetchResult = { old, new ->
if (old != null && new != old) {
with(schoolDb) {
deleteAll(listOf(old))
insertAll(listOf(new))
} }
} else if (old == null) {
schoolDb.insertAll(listOf(new))
} }
) refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, student))
}
)
} }

View File

@ -26,7 +26,7 @@ class SemesterRepository @Inject constructor(
student: Student, student: Student,
forceRefresh: Boolean = false, forceRefresh: Boolean = false,
refreshOnNoCurrent: Boolean = false refreshOnNoCurrent: Boolean = false
) = withContext(dispatchers.backgroundThread) { ) = withContext(dispatchers.io) {
val semesters = semesterDb.loadAll(student.studentId, student.classId) val semesters = semesterDb.loadAll(student.studentId, student.classId)
if (isShouldFetch(student, semesters, forceRefresh, refreshOnNoCurrent)) { if (isShouldFetch(student, semesters, forceRefresh, refreshOnNoCurrent)) {
@ -64,7 +64,7 @@ class SemesterRepository @Inject constructor(
} }
suspend fun getCurrentSemester(student: Student, forceRefresh: Boolean = false) = suspend fun getCurrentSemester(student: Student, forceRefresh: Boolean = false) =
withContext(dispatchers.backgroundThread) { withContext(dispatchers.io) {
getSemesters(student, forceRefresh).getCurrentOrLast() getSemesters(student, forceRefresh).getCurrentOrLast()
} }
} }

View File

@ -19,24 +19,27 @@ class StudentInfoRepository @Inject constructor(
private val saveFetchResultMutex = Mutex() private val saveFetchResultMutex = Mutex()
fun getStudentInfo(student: Student, semester: Semester, forceRefresh: Boolean) = fun getStudentInfo(
networkBoundResource( student: Student,
mutex = saveFetchResultMutex, semester: Semester,
shouldFetch = { it == null || forceRefresh }, forceRefresh: Boolean,
query = { studentInfoDao.loadStudentInfo(student.studentId) }, ) = networkBoundResource(
fetch = { mutex = saveFetchResultMutex,
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) shouldFetch = { it == null || forceRefresh },
.getStudentInfo().mapToEntity(semester) query = { studentInfoDao.loadStudentInfo(student.studentId) },
}, fetch = {
saveFetchResult = { old, new -> sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
if (old != null && new != old) { .getStudentInfo().mapToEntity(semester)
with(studentInfoDao) { },
deleteAll(listOf(old)) saveFetchResult = { old, new ->
insertAll(listOf(new)) if (old != null && new != old) {
} with(studentInfoDao) {
} else if (old == null) { deleteAll(listOf(old))
studentInfoDao.insertAll(listOf(new)) insertAll(listOf(new))
} }
} else if (old == null) {
studentInfoDao.insertAll(listOf(new))
} }
) }
)
} }

View File

@ -1,7 +1,9 @@
package io.github.wulkanowy.data.repositories package io.github.wulkanowy.data.repositories
import android.content.Context import android.content.Context
import androidx.room.withTransaction
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.data.db.AppDatabase
import io.github.wulkanowy.data.db.dao.SemesterDao import io.github.wulkanowy.data.db.dao.SemesterDao
import io.github.wulkanowy.data.db.dao.StudentDao import io.github.wulkanowy.data.db.dao.StudentDao
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
@ -25,7 +27,8 @@ class StudentRepository @Inject constructor(
private val studentDb: StudentDao, private val studentDb: StudentDao,
private val semesterDb: SemesterDao, private val semesterDb: SemesterDao,
private val sdk: Sdk, private val sdk: Sdk,
private val appInfo: AppInfo private val appInfo: AppInfo,
private val appDatabase: AppDatabase
) { ) {
suspend fun isStudentSaved() = getSavedStudents(false).isNotEmpty() suspend fun isStudentSaved() = getSavedStudents(false).isNotEmpty()
@ -63,7 +66,7 @@ class StudentRepository @Inject constructor(
.map { .map {
it.apply { it.apply {
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) { if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = withContext(dispatchers.backgroundThread) { student.password = withContext(dispatchers.io) {
decrypt(student.password) decrypt(student.password)
} }
} }
@ -74,7 +77,7 @@ class StudentRepository @Inject constructor(
val student = studentDb.loadById(id) ?: throw NoCurrentStudentException() val student = studentDb.loadById(id) ?: throw NoCurrentStudentException()
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) { if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = withContext(dispatchers.backgroundThread) { student.password = withContext(dispatchers.io) {
decrypt(student.password) decrypt(student.password)
} }
} }
@ -85,35 +88,40 @@ class StudentRepository @Inject constructor(
val student = studentDb.loadCurrent() ?: throw NoCurrentStudentException() val student = studentDb.loadCurrent() ?: throw NoCurrentStudentException()
if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) { if (decryptPass && Sdk.Mode.valueOf(student.loginMode) != Sdk.Mode.API) {
student.password = withContext(dispatchers.backgroundThread) { student.password = withContext(dispatchers.io) {
decrypt(student.password) decrypt(student.password)
} }
} }
return student return student
} }
suspend fun saveStudents(studentsWithSemesters: List<StudentWithSemesters>): List<Long> { suspend fun saveStudents(studentsWithSemesters: List<StudentWithSemesters>) {
val semesters = studentsWithSemesters.flatMap { it.semesters } val semesters = studentsWithSemesters.flatMap { it.semesters }
val students = studentsWithSemesters.map { it.student } val students = studentsWithSemesters.map { it.student }
.map { .map {
it.apply { it.apply {
if (Sdk.Mode.valueOf(it.loginMode) != Sdk.Mode.API) { if (Sdk.Mode.valueOf(it.loginMode) != Sdk.Mode.API) {
password = withContext(dispatchers.backgroundThread) { password = withContext(dispatchers.io) {
encrypt(password, context) encrypt(password, context)
} }
} }
} }
} }
.mapIndexed { index, student ->
if (index == 0) {
student.copy(isCurrent = true).apply { avatarColor = student.avatarColor }
} else student
}
semesterDb.insertSemesters(semesters) appDatabase.withTransaction {
return studentDb.insertAll(students) studentDb.resetCurrent()
semesterDb.insertSemesters(semesters)
studentDb.insertAll(students)
}
} }
suspend fun switchStudent(studentWithSemesters: StudentWithSemesters) { suspend fun switchStudent(studentWithSemesters: StudentWithSemesters) {
with(studentDb) { studentDb.switchCurrent(studentWithSemesters.student.id)
resetCurrent()
updateCurrent(studentWithSemesters.student.id)
}
} }
suspend fun logoutStudent(student: Student) = studentDb.delete(student) suspend fun logoutStudent(student: Student) = studentDb.delete(student)

View File

@ -5,6 +5,8 @@ import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.mappers.mapToEntities import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.networkBoundResource import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
@ -15,14 +17,24 @@ import javax.inject.Singleton
@Singleton @Singleton
class SubjectRepository @Inject constructor( class SubjectRepository @Inject constructor(
private val subjectDao: SubjectDao, private val subjectDao: SubjectDao,
private val sdk: Sdk private val sdk: Sdk,
private val refreshHelper: AutoRefreshHelper,
) { ) {
private val saveFetchResultMutex = Mutex() private val saveFetchResultMutex = Mutex()
fun getSubjects(student: Student, semester: Semester, forceRefresh: Boolean = false) = networkBoundResource( private val cacheKey = "subjects"
fun getSubjects(
student: Student,
semester: Semester,
forceRefresh: Boolean = false,
) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { it.isEmpty() || forceRefresh }, shouldFetch = {
val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, semester))
it.isEmpty() || forceRefresh || isExpired
},
query = { subjectDao.loadAll(semester.diaryId, semester.studentId) }, query = { subjectDao.loadAll(semester.diaryId, semester.studentId) },
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
@ -31,6 +43,8 @@ class SubjectRepository @Inject constructor(
saveFetchResult = { old, new -> saveFetchResult = { old, new ->
subjectDao.deleteAll(old uniqueSubtract new) subjectDao.deleteAll(old uniqueSubtract new)
subjectDao.insertAll(new uniqueSubtract old) subjectDao.insertAll(new uniqueSubtract old)
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester))
} }
) )
} }

View File

@ -5,6 +5,8 @@ import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.mappers.mapToEntities import io.github.wulkanowy.data.mappers.mapToEntities
import io.github.wulkanowy.sdk.Sdk import io.github.wulkanowy.sdk.Sdk
import io.github.wulkanowy.utils.AutoRefreshHelper
import io.github.wulkanowy.utils.getRefreshKey
import io.github.wulkanowy.utils.init import io.github.wulkanowy.utils.init
import io.github.wulkanowy.utils.networkBoundResource import io.github.wulkanowy.utils.networkBoundResource
import io.github.wulkanowy.utils.uniqueSubtract import io.github.wulkanowy.utils.uniqueSubtract
@ -15,14 +17,24 @@ import javax.inject.Singleton
@Singleton @Singleton
class TeacherRepository @Inject constructor( class TeacherRepository @Inject constructor(
private val teacherDb: TeacherDao, private val teacherDb: TeacherDao,
private val sdk: Sdk private val sdk: Sdk,
private val refreshHelper: AutoRefreshHelper,
) { ) {
private val saveFetchResultMutex = Mutex() private val saveFetchResultMutex = Mutex()
fun getTeachers(student: Student, semester: Semester, forceRefresh: Boolean) = networkBoundResource( private val cacheKey = "teachers"
fun getTeachers(
student: Student,
semester: Semester,
forceRefresh: Boolean,
) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { it.isEmpty() || forceRefresh }, shouldFetch = {
val isExpired = refreshHelper.shouldBeRefreshed(getRefreshKey(cacheKey, semester))
it.isEmpty() || forceRefresh || isExpired
},
query = { teacherDb.loadAll(semester.studentId, semester.classId) }, query = { teacherDb.loadAll(semester.studentId, semester.classId) },
fetch = { fetch = {
sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear) sdk.init(student).switchDiary(semester.diaryId, semester.schoolYear)
@ -32,6 +44,8 @@ class TeacherRepository @Inject constructor(
saveFetchResult = { old, new -> saveFetchResult = { old, new ->
teacherDb.deleteAll(old uniqueSubtract new) teacherDb.deleteAll(old uniqueSubtract new)
teacherDb.insertAll(new uniqueSubtract old) teacherDb.insertAll(new uniqueSubtract old)
refreshHelper.updateLastRefreshTimestamp(getRefreshKey(cacheKey, semester))
} }
) )
} }

View File

@ -41,18 +41,23 @@ class TimetableRepository @Inject constructor(
private val cacheKey = "timetable" private val cacheKey = "timetable"
fun getTimetable( fun getTimetable(
student: Student, semester: Semester, start: LocalDate, end: LocalDate, student: Student,
forceRefresh: Boolean, refreshAdditional: Boolean = false semester: Semester,
start: LocalDate,
end: LocalDate,
forceRefresh: Boolean,
refreshAdditional: Boolean = false,
notify: Boolean = false
) = networkBoundResource( ) = networkBoundResource(
mutex = saveFetchResultMutex, mutex = saveFetchResultMutex,
shouldFetch = { (timetable, additional, headers) -> shouldFetch = { (timetable, additional, headers) ->
val refreshKey = getRefreshKey(cacheKey, semester, start, end) val refreshKey = getRefreshKey(cacheKey, semester, start, end)
val isShouldRefresh = refreshHelper.isShouldBeRefreshed(refreshKey) val isExpired = refreshHelper.shouldBeRefreshed(refreshKey)
val isRefreshAdditional = additional.isEmpty() && refreshAdditional val isRefreshAdditional = additional.isEmpty() && refreshAdditional
val isNoData = timetable.isEmpty() || isRefreshAdditional || headers.isEmpty() val isNoData = timetable.isEmpty() || isRefreshAdditional || headers.isEmpty()
isNoData || forceRefresh || isShouldRefresh isNoData || forceRefresh || isExpired
}, },
query = { getFullTimetableFromDatabase(student, semester, start, end) }, query = { getFullTimetableFromDatabase(student, semester, start, end) },
fetch = { fetch = {
@ -63,7 +68,7 @@ class TimetableRepository @Inject constructor(
timetableFull.mapToEntities(semester) timetableFull.mapToEntities(semester)
}, },
saveFetchResult = { timetableOld, timetableNew -> saveFetchResult = { timetableOld, timetableNew ->
refreshTimetable(student, timetableOld.lessons, timetableNew.lessons) refreshTimetable(student, timetableOld.lessons, timetableNew.lessons, notify)
refreshAdditional(timetableOld.additional, timetableNew.additional) refreshAdditional(timetableOld.additional, timetableNew.additional)
refreshDayHeaders(timetableOld.headers, timetableNew.headers) refreshDayHeaders(timetableOld.headers, timetableNew.headers)
@ -79,8 +84,10 @@ class TimetableRepository @Inject constructor(
) )
private fun getFullTimetableFromDatabase( private fun getFullTimetableFromDatabase(
student: Student, semester: Semester, student: Student,
start: LocalDate, end: LocalDate semester: Semester,
start: LocalDate,
end: LocalDate,
): Flow<TimetableFull> { ): Flow<TimetableFull> {
val timetableFlow = timetableDb.loadAll( val timetableFlow = timetableDb.loadAll(
diaryId = semester.diaryId, diaryId = semester.diaryId,
@ -111,21 +118,27 @@ class TimetableRepository @Inject constructor(
} }
} }
fun getTimetableFromDatabase(
semester: Semester,
from: LocalDate,
end: LocalDate
): Flow<List<Timetable>> {
return timetableDb.loadAll(semester.diaryId, semester.studentId, from, end)
}
suspend fun updateTimetable(timetable: List<Timetable>) {
return timetableDb.updateAll(timetable)
}
private suspend fun refreshTimetable( private suspend fun refreshTimetable(
student: Student, student: Student,
lessonsOld: List<Timetable>, lessonsNew: List<Timetable> lessonsOld: List<Timetable>,
lessonsNew: List<Timetable>,
notify: Boolean
) { ) {
val lessonsToRemove = lessonsOld uniqueSubtract lessonsNew val lessonsToRemove = lessonsOld uniqueSubtract lessonsNew
val lessonsToAdd = (lessonsNew uniqueSubtract lessonsOld).map { new -> val lessonsToAdd = (lessonsNew uniqueSubtract lessonsOld).map { new ->
val matchingOld = lessonsOld.singleOrNull { new.start == it.start } new.apply { if (notify) isNotified = false }
if (matchingOld != null) {
val useOldTeacher = new.teacher.isEmpty() && !new.changes && !matchingOld.changes
new.copy(
room = if (new.room.isEmpty()) matchingOld.room else new.room,
teacher = if (useOldTeacher) matchingOld.teacher
else new.teacher
)
} else new
} }
timetableDb.deleteAll(lessonsToRemove) timetableDb.deleteAll(lessonsToRemove)

View File

@ -15,6 +15,7 @@ import dagger.multibindings.IntoSet
import io.github.wulkanowy.services.sync.channels.Channel import io.github.wulkanowy.services.sync.channels.Channel
import io.github.wulkanowy.services.sync.channels.DebugChannel import io.github.wulkanowy.services.sync.channels.DebugChannel
import io.github.wulkanowy.services.sync.channels.LuckyNumberChannel import io.github.wulkanowy.services.sync.channels.LuckyNumberChannel
import io.github.wulkanowy.services.sync.channels.NewAttendanceChannel
import io.github.wulkanowy.services.sync.channels.NewConferencesChannel import io.github.wulkanowy.services.sync.channels.NewConferencesChannel
import io.github.wulkanowy.services.sync.channels.NewExamChannel import io.github.wulkanowy.services.sync.channels.NewExamChannel
import io.github.wulkanowy.services.sync.channels.NewGradesChannel import io.github.wulkanowy.services.sync.channels.NewGradesChannel
@ -23,6 +24,7 @@ import io.github.wulkanowy.services.sync.channels.NewMessagesChannel
import io.github.wulkanowy.services.sync.channels.NewNotesChannel import io.github.wulkanowy.services.sync.channels.NewNotesChannel
import io.github.wulkanowy.services.sync.channels.NewSchoolAnnouncementsChannel import io.github.wulkanowy.services.sync.channels.NewSchoolAnnouncementsChannel
import io.github.wulkanowy.services.sync.channels.PushChannel import io.github.wulkanowy.services.sync.channels.PushChannel
import io.github.wulkanowy.services.sync.channels.TimetableChangeChannel
import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel
import io.github.wulkanowy.services.sync.works.AttendanceSummaryWork import io.github.wulkanowy.services.sync.works.AttendanceSummaryWork
import io.github.wulkanowy.services.sync.works.AttendanceWork import io.github.wulkanowy.services.sync.works.AttendanceWork
@ -167,4 +169,12 @@ abstract class ServicesModule {
@Binds @Binds
@IntoSet @IntoSet
abstract fun provideUpcomingLessonsChannel(channel: UpcomingLessonsChannel): Channel abstract fun provideUpcomingLessonsChannel(channel: UpcomingLessonsChannel): Channel
@Binds
@IntoSet
abstract fun provideChangeTimetableChannel(channel: TimetableChangeChannel): Channel
@Binds
@IntoSet
abstract fun provideNewAttendanceChannel(channel: NewAttendanceChannel): Channel
} }

View File

@ -1,7 +1,6 @@
package io.github.wulkanowy.services.alarm package io.github.wulkanowy.services.alarm
import android.app.PendingIntent import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
@ -11,11 +10,13 @@ import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.Status import io.github.wulkanowy.data.Status
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.StudentRepository import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.services.HiltBroadcastReceiver import io.github.wulkanowy.services.HiltBroadcastReceiver
import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel.Companion.CHANNEL_ID import io.github.wulkanowy.services.sync.channels.UpcomingLessonsChannel.Companion.CHANNEL_ID
import io.github.wulkanowy.ui.modules.main.MainActivity import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.PendingIntentCompat
import io.github.wulkanowy.utils.flowWithResource import io.github.wulkanowy.utils.flowWithResource
import io.github.wulkanowy.utils.getCompatColor import io.github.wulkanowy.utils.getCompatColor
import io.github.wulkanowy.utils.toLocalDateTime import io.github.wulkanowy.utils.toLocalDateTime
@ -32,12 +33,15 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
@Inject @Inject
lateinit var studentRepository: StudentRepository lateinit var studentRepository: StudentRepository
@Inject
lateinit var preferencesRepository: PreferencesRepository
companion object { companion object {
const val NOTIFICATION_TYPE_CURRENT = 1 const val NOTIFICATION_TYPE_CURRENT = 1
const val NOTIFICATION_TYPE_UPCOMING = 2 const val NOTIFICATION_TYPE_UPCOMING = 2
const val NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION = 3 const val NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION = 3
const val NOTIFICATION_ID = "id" const val NOTIFICATION_ID = 2137
const val STUDENT_NAME = "student_name" const val STUDENT_NAME = "student_name"
const val STUDENT_ID = "student_id" const val STUDENT_ID = "student_id"
@ -67,10 +71,10 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
private fun prepareNotification(context: Context, intent: Intent) { private fun prepareNotification(context: Context, intent: Intent) {
val type = intent.getIntExtra(LESSON_TYPE, 0) val type = intent.getIntExtra(LESSON_TYPE, 0)
val notificationId = intent.getIntExtra(NOTIFICATION_ID, MainView.Section.TIMETABLE.id) val isPersistent = preferencesRepository.isUpcomingLessonsNotificationsPersistent
if (type == NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION) { if (type == NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION) {
return NotificationManagerCompat.from(context).cancel(notificationId) return NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
} }
val studentId = intent.getIntExtra(STUDENT_ID, 0) val studentId = intent.getIntExtra(STUDENT_ID, 0)
@ -87,33 +91,58 @@ class TimetableNotificationReceiver : HiltBroadcastReceiver() {
Timber.d("TimetableNotification receive: type: $type, subject: $subject, start: ${start.toLocalDateTime()}, student: $studentId") Timber.d("TimetableNotification receive: type: $type, subject: $subject, start: ${start.toLocalDateTime()}, student: $studentId")
showNotification(context, notificationId, studentName, showNotification(
context, isPersistent, studentName,
if (type == NOTIFICATION_TYPE_CURRENT) end else start, end - start, if (type == NOTIFICATION_TYPE_CURRENT) end else start, end - start,
context.getString(if (type == NOTIFICATION_TYPE_CURRENT) R.string.timetable_now else R.string.timetable_next, "($room) $subject".removePrefix("()")), context.getString(
nextSubject?.let { context.getString(R.string.timetable_later, "($nextRoom) $nextSubject".removePrefix("()")) } if (type == NOTIFICATION_TYPE_CURRENT) R.string.timetable_now else R.string.timetable_next,
"($room) $subject".removePrefix("()")
),
nextSubject?.let {
context.getString(
R.string.timetable_later,
"($nextRoom) $nextSubject".removePrefix("()")
)
}
) )
} }
private fun showNotification(context: Context, notificationId: Int, studentName: String?, countDown: Long, timeout: Long, title: String, next: String?) { private fun showNotification(
NotificationManagerCompat.from(context).notify(notificationId, NotificationCompat.Builder(context, CHANNEL_ID) context: Context,
.setContentTitle(title) isPersistent: Boolean,
.setContentText(next) studentName: String?,
.setAutoCancel(false) countDown: Long,
.setOngoing(true) timeout: Long,
.setWhen(countDown) title: String,
.apply { next: String?
if (Build.VERSION.SDK_INT >= N) setUsesChronometer(true) ) {
} NotificationManagerCompat.from(context)
.setTimeoutAfter(timeout) .notify(NOTIFICATION_ID, NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_timetable) .setContentTitle(title)
.setColor(context.getCompatColor(R.color.colorPrimary)) .setContentText(next)
.setStyle(NotificationCompat.InboxStyle().also { .setAutoCancel(false)
it.setSummaryText(studentName) .setWhen(countDown)
it.addLine(next) .setOngoing(isPersistent)
}) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(PendingIntent.getActivity(context, MainView.Section.TIMETABLE.id, .apply {
MainActivity.getStartIntent(context, MainView.Section.TIMETABLE, true), FLAG_UPDATE_CURRENT)) if (Build.VERSION.SDK_INT >= N) setUsesChronometer(true)
.build() }
) .setTimeoutAfter(timeout)
.setSmallIcon(R.drawable.ic_stat_timetable)
.setColor(context.getCompatColor(R.color.colorPrimary))
.setStyle(NotificationCompat.InboxStyle().also {
it.setSummaryText(studentName)
it.addLine(next)
})
.setContentIntent(
PendingIntent.getActivity(
context,
NOTIFICATION_ID,
SplashActivity.getStartIntent(context, Destination.Timetable()),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
.build()
)
} }
} }

View File

@ -3,9 +3,9 @@ package io.github.wulkanowy.services.alarm
import android.app.AlarmManager import android.app.AlarmManager
import android.app.AlarmManager.RTC_WAKEUP import android.app.AlarmManager.RTC_WAKEUP
import android.app.PendingIntent import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.app.AlarmManagerCompat import androidx.core.app.AlarmManagerCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
@ -25,12 +25,13 @@ import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companio
import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.NOTIFICATION_TYPE_UPCOMING import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.NOTIFICATION_TYPE_UPCOMING
import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.STUDENT_ID import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.STUDENT_ID
import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.STUDENT_NAME import io.github.wulkanowy.services.alarm.TimetableNotificationReceiver.Companion.STUDENT_NAME
import io.github.wulkanowy.ui.modules.main.MainView
import io.github.wulkanowy.utils.DispatchersProvider import io.github.wulkanowy.utils.DispatchersProvider
import io.github.wulkanowy.utils.PendingIntentCompat
import io.github.wulkanowy.utils.nickOrName import io.github.wulkanowy.utils.nickOrName
import io.github.wulkanowy.utils.toTimestamp import io.github.wulkanowy.utils.toTimestamp
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.LocalDateTime.now import java.time.LocalDateTime.now
import javax.inject.Inject import javax.inject.Inject
@ -53,14 +54,17 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
suspend fun cancelScheduled(lessons: List<Timetable>, student: Student) { suspend fun cancelScheduled(lessons: List<Timetable>, student: Student) {
val studentId = student.studentId val studentId = student.studentId
withContext(dispatchersProvider.backgroundThread) { withContext(dispatchersProvider.io) {
lessons.sortedBy { it.start }.forEachIndexed { index, lesson -> lessons.sortedBy { it.start }.forEachIndexed { index, lesson ->
val upcomingTime = getUpcomingLessonTime(index, lessons, lesson) val upcomingTime = getUpcomingLessonTime(index, lessons, lesson)
cancelScheduledTo( cancelScheduledTo(
upcomingTime..lesson.start, range = upcomingTime..lesson.start,
getRequestCode(upcomingTime, studentId) requestCode = getRequestCode(upcomingTime, studentId)
)
cancelScheduledTo(
range = lesson.start..lesson.end,
requestCode = getRequestCode(lesson.start, studentId)
) )
cancelScheduledTo(lesson.start..lesson.end, getRequestCode(lesson.start, studentId))
Timber.d("TimetableNotification canceled: type 1 & 2, subject: ${lesson.subject}, start: ${lesson.start}, student: $studentId") Timber.d("TimetableNotification canceled: type 1 & 2, subject: ${lesson.subject}, start: ${lesson.start}, student: $studentId")
} }
@ -69,20 +73,37 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
private fun cancelScheduledTo(range: ClosedRange<LocalDateTime>, requestCode: Int) { private fun cancelScheduledTo(range: ClosedRange<LocalDateTime>, requestCode: Int) {
if (now() in range) cancelNotification() if (now() in range) cancelNotification()
alarmManager.cancel( alarmManager.cancel(
PendingIntent.getBroadcast(context, requestCode, Intent(), FLAG_UPDATE_CURRENT) PendingIntent.getBroadcast(
context,
requestCode,
Intent(),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
) )
} }
fun cancelNotification() = fun cancelNotification() =
NotificationManagerCompat.from(context).cancel(MainView.Section.TIMETABLE.id) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
suspend fun scheduleNotifications(lessons: List<Timetable>, student: Student) { suspend fun scheduleNotifications(lessons: List<Timetable>, student: Student) {
if (!preferencesRepository.isUpcomingLessonsNotificationsEnable) { if (!preferencesRepository.isUpcomingLessonsNotificationsEnable) {
return cancelScheduled(lessons, student) return cancelScheduled(lessons, student)
} }
withContext(dispatchersProvider.backgroundThread) { if (!canScheduleExactAlarms()) {
Timber.w("Exact alarms are disabled by user")
preferencesRepository.isUpcomingLessonsNotificationsEnable = false
return
}
if (lessons.firstOrNull()?.date?.isAfter(LocalDate.now().plusDays(2)) == true) {
Timber.d("Timetable notification scheduling skipped - lessons are too far")
return
}
withContext(dispatchersProvider.io) {
lessons.groupBy { it.date } lessons.groupBy { it.date }
.map { it.value.sortedBy { lesson -> lesson.start } } .map { it.value.sortedBy { lesson -> lesson.start } }
.map { it.filter { lesson -> lesson.isStudentPlan } } .map { it.filter { lesson -> lesson.isStudentPlan } }
@ -96,26 +117,26 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
if (lesson.start > now()) { if (lesson.start > now()) {
scheduleBroadcast( scheduleBroadcast(
intent, intent = intent,
student.studentId, studentId = student.studentId,
NOTIFICATION_TYPE_UPCOMING, notificationType = NOTIFICATION_TYPE_UPCOMING,
getUpcomingLessonTime(index, active, lesson) time = getUpcomingLessonTime(index, active, lesson)
) )
} }
if (lesson.end > now()) { if (lesson.end > now()) {
scheduleBroadcast( scheduleBroadcast(
intent, intent = intent,
student.studentId, studentId = student.studentId,
NOTIFICATION_TYPE_CURRENT, notificationType = NOTIFICATION_TYPE_CURRENT,
lesson.start time = lesson.start
) )
if (active.lastIndex == index) { if (active.lastIndex == index) {
scheduleBroadcast( scheduleBroadcast(
intent, intent = intent,
student.studentId, studentId = student.studentId,
NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION, notificationType = NOTIFICATION_TYPE_LAST_LESSON_CANCELLATION,
lesson.end time = lesson.end
) )
} }
} }
@ -143,17 +164,30 @@ class TimetableNotificationSchedulerHelper @Inject constructor(
notificationType: Int, notificationType: Int,
time: LocalDateTime time: LocalDateTime
) { ) {
AlarmManagerCompat.setExactAndAllowWhileIdle( try {
alarmManager, RTC_WAKEUP, time.toTimestamp(), AlarmManagerCompat.setExactAndAllowWhileIdle(
PendingIntent.getBroadcast(context, getRequestCode(time, studentId), intent.also { alarmManager, RTC_WAKEUP, time.toTimestamp(),
it.putExtra(NOTIFICATION_ID, MainView.Section.TIMETABLE.id) PendingIntent.getBroadcast(context, getRequestCode(time, studentId), intent.also {
it.putExtra(LESSON_TYPE, notificationType) it.putExtra(LESSON_TYPE, notificationType)
}, FLAG_UPDATE_CURRENT) }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE)
) )
Timber.d( Timber.d(
"TimetableNotification scheduled: type: $notificationType, subject: ${ "TimetableNotification scheduled: type: $notificationType, subject: ${
intent.getStringExtra(LESSON_TITLE) intent.getStringExtra(LESSON_TITLE)
}, start: $time, student: $studentId" }, start: $time, student: $studentId"
) )
} catch (e: Throwable) {
Timber.e(e)
}
}
fun canScheduleExactAlarms(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
alarmManager.canScheduleExactAlarms()
} catch (e: Throwable) {
false
}
} else true
} }
} }

View File

@ -0,0 +1,24 @@
package io.github.wulkanowy.services.piggyback
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.services.sync.SyncManager
import javax.inject.Inject
@AndroidEntryPoint
class VulcanNotificationListenerService : NotificationListenerService() {
@Inject
lateinit var syncManager: SyncManager
@Inject
lateinit var preferenceRepository: PreferencesRepository
override fun onNotificationPosted(statusBarNotification: StatusBarNotification?) {
if (statusBarNotification?.packageName == "pl.edu.vulcan.hebe" && preferenceRepository.isNotificationPiggybackEnabled) {
syncManager.startOneTimeSyncWorker()
}
}
}

View File

@ -0,0 +1,90 @@
package io.github.wulkanowy.services.shortcuts
import android.content.Context
import android.content.Intent
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ShortcutsHelper @Inject constructor(@ApplicationContext private val context: Context) {
private val destinations = mapOf(
"grade" to Destination.Grade,
"attendance" to Destination.Attendance,
"exam" to Destination.Exam,
"timetable" to Destination.Timetable()
)
init {
initializeShortcuts()
}
fun getDestination(intent: Intent) =
destinations[intent.getStringExtra(EXTRA_SHORTCUT_DESTINATION_ID)]
private fun initializeShortcuts() {
val shortcutsInfo = listOf(
ShortcutInfoCompat.Builder(context, "grade_shortcut")
.setShortLabel(context.getString(R.string.grade_title))
.setLongLabel(context.getString(R.string.grade_title))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_grade))
.setIntent(SplashActivity.getStartIntent(context)
.apply {
action = Intent.ACTION_VIEW
putExtra(EXTRA_SHORTCUT_DESTINATION_ID, "grade")
}
)
.build(),
ShortcutInfoCompat.Builder(context, "attendance_shortcut")
.setShortLabel(context.getString(R.string.attendance_title))
.setLongLabel(context.getString(R.string.attendance_title))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_attendance))
.setIntent(SplashActivity.getStartIntent(context)
.apply {
action = Intent.ACTION_VIEW
putExtra(EXTRA_SHORTCUT_DESTINATION_ID, "attendance")
}
)
.build(),
ShortcutInfoCompat.Builder(context, "exam_shortcut")
.setShortLabel(context.getString(R.string.exam_title))
.setLongLabel(context.getString(R.string.exam_title))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_exam))
.setIntent(SplashActivity.getStartIntent(context)
.apply {
action = Intent.ACTION_VIEW
putExtra(EXTRA_SHORTCUT_DESTINATION_ID, "exam")
}
)
.build(),
ShortcutInfoCompat.Builder(context, "timetable_shortcut")
.setShortLabel(context.getString(R.string.timetable_title))
.setLongLabel(context.getString(R.string.timetable_title))
.setIcon(IconCompat.createWithResource(context, R.drawable.ic_shortcut_timetable))
.setIntent(SplashActivity.getStartIntent(context)
.apply {
action = Intent.ACTION_VIEW
putExtra(EXTRA_SHORTCUT_DESTINATION_ID, "timetable")
}
)
.build()
)
shortcutsInfo.forEach { ShortcutManagerCompat.pushDynamicShortcut(context, it) }
}
private companion object {
private const val EXTRA_SHORTCUT_DESTINATION_ID = "shortcut_destination_id"
}
}

View File

@ -57,18 +57,24 @@ class SyncManager @Inject constructor(
fun startPeriodicSyncWorker(restart: Boolean = false) { fun startPeriodicSyncWorker(restart: Boolean = false) {
if (preferencesRepository.isServiceEnabled && !now().isHolidays) { if (preferencesRepository.isServiceEnabled && !now().isHolidays) {
workManager.enqueueUniquePeriodicWork(SyncWorker::class.java.simpleName, if (restart) REPLACE else KEEP, val serviceInterval = preferencesRepository.servicesInterval
PeriodicWorkRequestBuilder<SyncWorker>(preferencesRepository.servicesInterval, MINUTES)
workManager.enqueueUniquePeriodicWork(
SyncWorker::class.java.simpleName, if (restart) REPLACE else KEEP,
PeriodicWorkRequestBuilder<SyncWorker>(serviceInterval, MINUTES)
.setInitialDelay(10, MINUTES) .setInitialDelay(10, MINUTES)
.setBackoffCriteria(EXPONENTIAL, 30, MINUTES) .setBackoffCriteria(EXPONENTIAL, 30, MINUTES)
.setConstraints(Constraints.Builder() .setConstraints(
.setRequiredNetworkType(if (preferencesRepository.isServicesOnlyWifi) UNMETERED else CONNECTED) Constraints.Builder()
.build()) .setRequiredNetworkType(if (preferencesRepository.isServicesOnlyWifi) UNMETERED else CONNECTED)
.build()) .build()
)
.build()
)
} }
} }
fun startOneTimeSyncWorker(): Flow<WorkInfo> { fun startOneTimeSyncWorker(): Flow<WorkInfo?> {
val work = OneTimeWorkRequestBuilder<SyncWorker>() val work = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData( .setInputData(
Data.Builder() Data.Builder()
@ -77,7 +83,11 @@ class SyncManager @Inject constructor(
) )
.build() .build()
workManager.enqueueUniqueWork("${SyncWorker::class.java.simpleName}_one_time", ExistingWorkPolicy.REPLACE, work) workManager.enqueueUniqueWork(
"${SyncWorker::class.java.simpleName}_one_time",
ExistingWorkPolicy.REPLACE,
work
)
return workManager.getWorkInfoByIdLiveData(work.id).asFlow() return workManager.getWorkInfoByIdLiveData(work.id).asFlow()
} }

View File

@ -19,11 +19,11 @@ import io.github.wulkanowy.sdk.exception.FeatureNotAvailableException
import io.github.wulkanowy.sdk.scrapper.exception.FeatureDisabledException import io.github.wulkanowy.sdk.scrapper.exception.FeatureDisabledException
import io.github.wulkanowy.services.sync.channels.DebugChannel import io.github.wulkanowy.services.sync.channels.DebugChannel
import io.github.wulkanowy.services.sync.works.Work import io.github.wulkanowy.services.sync.works.Work
import io.github.wulkanowy.utils.DispatchersProvider
import io.github.wulkanowy.utils.getCompatColor import io.github.wulkanowy.utils.getCompatColor
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.random.Random import kotlin.random.Random
@HiltWorker @HiltWorker
@ -34,13 +34,14 @@ class SyncWorker @AssistedInject constructor(
private val semesterRepository: SemesterRepository, private val semesterRepository: SemesterRepository,
private val works: Set<@JvmSuppressWildcards Work>, private val works: Set<@JvmSuppressWildcards Work>,
private val preferencesRepository: PreferencesRepository, private val preferencesRepository: PreferencesRepository,
private val notificationManager: NotificationManagerCompat private val notificationManager: NotificationManagerCompat,
private val dispatchersProvider: DispatchersProvider
) : CoroutineWorker(appContext, workerParameters) { ) : CoroutineWorker(appContext, workerParameters) {
override suspend fun doWork() = coroutineScope { override suspend fun doWork() = withContext(dispatchersProvider.io) {
Timber.i("SyncWorker is starting") Timber.i("SyncWorker is starting")
if (!studentRepository.isCurrentStudentSet()) return@coroutineScope Result.failure() if (!studentRepository.isCurrentStudentSet()) return@withContext Result.failure()
val student = studentRepository.getCurrentStudent() val student = studentRepository.getCurrentStudent()
val semester = semesterRepository.getCurrentSemester(student, true) val semester = semesterRepository.getCurrentSemester(student, true)
@ -50,12 +51,12 @@ class SyncWorker @AssistedInject constructor(
Timber.i("${work::class.java.simpleName} is starting") Timber.i("${work::class.java.simpleName} is starting")
work.doWork(student, semester) work.doWork(student, semester)
Timber.i("${work::class.java.simpleName} result: Success") Timber.i("${work::class.java.simpleName} result: Success")
preferencesRepository.lasSyncDate = LocalDateTime.now(ZoneId.systemDefault())
null null
} catch (e: Throwable) { } catch (e: Throwable) {
Timber.w("${work::class.java.simpleName} result: An exception ${e.message} occurred") Timber.w("${work::class.java.simpleName} result: An exception ${e.message} occurred")
if (e is FeatureDisabledException || e is FeatureNotAvailableException) null if (e is FeatureDisabledException || e is FeatureNotAvailableException) {
else { null
} else {
Timber.e(e) Timber.e(e)
e e
} }
@ -70,13 +71,16 @@ class SyncWorker @AssistedInject constructor(
) )
} }
exceptions.isNotEmpty() -> Result.retry() exceptions.isNotEmpty() -> Result.retry()
else -> Result.success() else -> {
preferencesRepository.lasSyncDate = LocalDateTime.now()
Result.success()
}
} }
if (preferencesRepository.isDebugNotificationEnable) notify(result) if (preferencesRepository.isDebugNotificationEnable) notify(result)
Timber.i("SyncWorker result: $result") Timber.i("SyncWorker result: $result")
result return@withContext result
} }
private fun notify(result: Result) { private fun notify(result: Result) {

View File

@ -0,0 +1,36 @@
package io.github.wulkanowy.services.sync.channels
import android.annotation.TargetApi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import javax.inject.Inject
@TargetApi(26)
class NewAttendanceChannel @Inject constructor(
private val notificationManager: NotificationManagerCompat,
@ApplicationContext private val context: Context
) : Channel {
companion object {
const val CHANNEL_ID = "new_attendance_channel"
}
override fun create() {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.channel_new_attendance),
NotificationManager.IMPORTANCE_HIGH
)
.apply {
enableLights(true)
enableVibration(true)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
})
}
}

View File

@ -0,0 +1,36 @@
package io.github.wulkanowy.services.sync.channels
import android.annotation.TargetApi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import javax.inject.Inject
@TargetApi(26)
class TimetableChangeChannel @Inject constructor(
private val notificationManager: NotificationManagerCompat,
@ApplicationContext private val context: Context
) : Channel {
companion object {
const val CHANNEL_ID = "change_timetable_channel"
}
override fun create() {
notificationManager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
context.getString(R.string.channel_change_timetable),
NotificationManager.IMPORTANCE_HIGH
)
.apply {
enableLights(true)
enableVibration(true)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
})
}
}

View File

@ -0,0 +1,180 @@
package io.github.wulkanowy.services.sync.notifications
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Notification
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.data.repositories.NotificationRepository
import io.github.wulkanowy.data.repositories.StudentRepository
import io.github.wulkanowy.utils.PendingIntentCompat
import io.github.wulkanowy.utils.getCompatBitmap
import io.github.wulkanowy.utils.getCompatColor
import io.github.wulkanowy.utils.nickOrName
import java.time.LocalDateTime
import javax.inject.Inject
import kotlin.random.Random
class AppNotificationManager @Inject constructor(
private val notificationManager: NotificationManagerCompat,
@ApplicationContext private val context: Context,
private val studentRepository: StudentRepository,
private val notificationRepository: NotificationRepository
) {
@SuppressLint("InlinedApi")
suspend fun sendSingleNotification(
notificationData: NotificationData,
notificationType: NotificationType,
student: Student
) {
val notification = NotificationCompat.Builder(context, notificationType.channel)
.setLargeIcon(context.getCompatBitmap(notificationType.icon, R.color.colorPrimary))
.setSmallIcon(R.drawable.ic_stat_all)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(context.getCompatColor(R.color.colorPrimary))
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setContentIntent(
PendingIntent.getActivity(
context,
Random.nextInt(),
notificationData.intentToStart,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
.setContentTitle(notificationData.title)
.setContentText(notificationData.content)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(notificationData.content)
.also { builder ->
if (shouldShowStudentName()) {
builder.setSummaryText(student.nickOrName)
}
}
)
.build()
notificationManager.notify(Random.nextInt(), notification)
saveNotification(notificationData, notificationType, student)
}
@SuppressLint("InlinedApi")
suspend fun sendMultipleNotifications(
groupNotificationData: GroupNotificationData,
student: Student
) {
val notificationType = groupNotificationData.type
val groupType = notificationType.group ?: return
val group = "${groupType}_${student.id}"
sendSummaryNotification(groupNotificationData, group, student)
groupNotificationData.notificationDataList.forEach { notificationData ->
val notification = NotificationCompat.Builder(context, notificationType.channel)
.setLargeIcon(context.getCompatBitmap(notificationType.icon, R.color.colorPrimary))
.setSmallIcon(R.drawable.ic_stat_all)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(context.getCompatColor(R.color.colorPrimary))
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
.setContentIntent(
PendingIntent.getActivity(
context,
Random.nextInt(),
notificationData.intentToStart,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
.setContentTitle(notificationData.title)
.setContentText(notificationData.content)
.setStyle(
NotificationCompat.BigTextStyle()
.bigText(notificationData.content)
.also { builder ->
if (shouldShowStudentName()) {
builder.setSummaryText(student.nickOrName)
}
}
)
.setGroup(group)
.build()
notificationManager.notify(Random.nextInt(), notification)
saveNotification(notificationData, groupNotificationData.type, student)
}
}
private suspend fun sendSummaryNotification(
groupNotificationData: GroupNotificationData,
group: String,
student: Student
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
val summaryNotification =
NotificationCompat.Builder(context, groupNotificationData.type.channel)
.setContentTitle(groupNotificationData.title)
.setContentText(groupNotificationData.content)
.setSmallIcon(groupNotificationData.type.icon)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(context.getCompatColor(R.color.colorPrimary))
.setStyle(
NotificationCompat.InboxStyle()
.also { builder ->
if (shouldShowStudentName()) {
builder.setSummaryText(student.nickOrName)
}
groupNotificationData.notificationDataList.forEach {
builder.addLine(it.content)
}
}
)
.setContentIntent(
PendingIntent.getActivity(
context,
Random.nextInt(),
groupNotificationData.intentToStart,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE
)
)
.setLocalOnly(true)
.setGroup(group)
.setGroupSummary(true)
.build()
val groupId = student.id * 100 + groupNotificationData.type.ordinal
notificationManager.notify(groupId.toInt(), summaryNotification)
}
private suspend fun saveNotification(
notificationData: NotificationData,
notificationType: NotificationType,
student: Student
) {
val notificationEntity = Notification(
studentId = student.id,
title = notificationData.title,
content = notificationData.content,
type = notificationType,
date = LocalDateTime.now()
)
notificationRepository.saveNotification(notificationEntity)
}
private suspend fun shouldShowStudentName(): Boolean =
studentRepository.getSavedStudents(decryptPass = false).size > 1
}

View File

@ -1,102 +0,0 @@
package io.github.wulkanowy.services.sync.notifications
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.annotation.PluralsRes
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotifications
import io.github.wulkanowy.data.pojos.Notification
import io.github.wulkanowy.data.pojos.OneNotification
import io.github.wulkanowy.ui.modules.main.MainActivity
import io.github.wulkanowy.utils.getCompatBitmap
import io.github.wulkanowy.utils.getCompatColor
import io.github.wulkanowy.utils.nickOrName
import kotlin.random.Random
abstract class BaseNotification(
private val context: Context,
private val notificationManager: NotificationManagerCompat,
) {
protected fun sendNotification(notification: Notification, student: Student) =
when (notification) {
is OneNotification -> sendOneNotification(notification, student)
is MultipleNotifications -> sendMultipleNotifications(notification, student)
}
private fun sendOneNotification(notification: OneNotification, student: Student?) {
notificationManager.notify(
Random.nextInt(Int.MAX_VALUE),
getNotificationBuilder(notification).apply {
val content = context.getString(
notification.contentStringRes,
*notification.contentValues.toTypedArray()
)
setContentTitle(context.getString(notification.titleStringRes))
setContentText(content)
setStyle(
NotificationCompat.BigTextStyle()
.setSummaryText(student?.nickOrName)
.bigText(content)
)
}.build()
)
}
private fun sendMultipleNotifications(notification: MultipleNotifications, student: Student) {
val group = notification.type.group + student.id
val groupId = student.id * 100 + notification.type.ordinal
notification.lines.forEach { item ->
notificationManager.notify(
Random.nextInt(Int.MAX_VALUE),
getNotificationBuilder(notification).apply {
setContentTitle(getQuantityString(notification.titleStringRes, 1))
setContentText(item)
setStyle(
NotificationCompat.BigTextStyle()
.setSummaryText(student.nickOrName)
.bigText(item)
)
setGroup(group)
}.build()
)
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
notificationManager.notify(
groupId.toInt(),
getNotificationBuilder(notification).apply {
setSmallIcon(notification.icon)
setGroup(group)
setStyle(NotificationCompat.InboxStyle().setSummaryText(student.nickOrName))
setGroupSummary(true)
}.build()
)
}
private fun getNotificationBuilder(notification: Notification) = NotificationCompat
.Builder(context, notification.type.channel)
.setLargeIcon(context.getCompatBitmap(notification.icon, R.color.colorPrimary))
.setSmallIcon(R.drawable.ic_stat_all)
.setAutoCancel(true)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setColor(context.getCompatColor(R.color.colorPrimary))
.setContentIntent(
PendingIntent.getActivity(
context, notification.startMenu.id,
MainActivity.getStartIntent(context, notification.startMenu, true),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
private fun getQuantityString(@PluralsRes id: Int, value: Int): String {
return context.resources.getQuantityString(id, value, value)
}
}

View File

@ -0,0 +1,124 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.db.entities.Timetable
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate
import java.time.LocalDateTime
import javax.inject.Inject
class ChangeTimetableNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context,
) {
suspend fun notify(items: List<Timetable>, student: Student) {
val currentTime = LocalDateTime.now()
val changedLessons = items.filter { (it.canceled || it.changes) && it.start > currentTime }
val notificationDataList = changedLessons.groupBy { it.date }
.map { (date, lessons) ->
getNotificationContents(date, lessons).map {
NotificationData(
title = context.getPlural(
R.plurals.timetable_notify_new_items_title,
1
),
content = it,
intentToStart = SplashActivity.getStartIntent(
context = context,
destination = Destination.Timetable(date)
)
)
}
}
.flatten()
.ifEmpty { return }
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(
R.plurals.timetable_notify_new_items_title,
changedLessons.size
),
content = context.getPlural(
R.plurals.timetable_notify_new_items_group,
changedLessons.size,
changedLessons.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Timetable()),
type = NotificationType.CHANGE_TIMETABLE
)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
private fun getNotificationContents(date: LocalDate, lessons: List<Timetable>): List<String> {
val formattedDate = date.toFormattedString("EEE dd.MM")
return if (lessons.size > 2) {
listOf(
context.getPlural(
R.plurals.timetable_notify_new_items,
lessons.size,
formattedDate,
lessons.size,
)
)
} else {
lessons.map {
buildString {
append(
context.getString(
R.string.timetable_notify_lesson,
formattedDate,
it.number,
it.subject
)
)
if (it.roomOld.isNotBlank()) {
appendLine()
append(
context.getString(
R.string.timetable_notify_change_room,
it.roomOld,
it.room
)
)
}
if (it.teacherOld.isNotBlank() && it.teacher != it.teacherOld) {
appendLine()
append(
context.getString(
R.string.timetable_notify_change_teacher,
it.teacherOld,
it.teacher
)
)
}
if (it.subjectOld.isNotBlank()) {
appendLine()
append(
context.getString(
R.string.timetable_notify_change_subject,
it.subjectOld,
it.subject
)
)
}
if (it.info.isNotBlank()) {
appendLine()
append(it.info)
}
}
}
}
}
}

View File

@ -0,0 +1,55 @@
package io.github.wulkanowy.services.sync.notifications
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Attendance
import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.descriptionRes
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString
import javax.inject.Inject
class NewAttendanceNotification @Inject constructor(
private val appNotificationManager: AppNotificationManager,
@ApplicationContext private val context: Context
) {
suspend fun notify(items: List<Attendance>, student: Student) {
val lines = items.filterNot { it.presence || it.name == "UNKNOWN" }
.map {
val description = context.getString(it.descriptionRes)
"${it.date.toFormattedString("dd.MM")} - ${it.subject}: $description"
}
.ifEmpty { return }
val notificationDataList = lines.map {
NotificationData(
title = context.getPlural(R.plurals.attendance_notify_new_items_title, 1),
content = it,
intentToStart = SplashActivity.getStartIntent(context, Destination.Attendance)
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(
R.plurals.attendance_notify_new_items_title,
notificationDataList.size
),
content = context.getPlural(
R.plurals.attendance_notify_new_items,
notificationDataList.size,
notificationDataList.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Attendance),
type = NotificationType.NEW_ATTENDANCE
)
appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
}
}

View File

@ -1,38 +1,52 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import android.content.Context import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Conference import io.github.wulkanowy.data.db.entities.Conference
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotifications import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.inject.Inject import javax.inject.Inject
class NewConferenceNotification @Inject constructor( class NewConferenceNotification @Inject constructor(
@ApplicationContext private val context: Context, private val appNotificationManager: AppNotificationManager,
notificationManager: NotificationManagerCompat, @ApplicationContext private val context: Context
) : BaseNotification(context, notificationManager) { ) {
fun notify(items: List<Conference>, student: Student) { suspend fun notify(items: List<Conference>, student: Student) {
val today = LocalDateTime.now() val today = LocalDateTime.now()
val lines = items.filter { !it.date.isBefore(today) }.map { val lines = items.filter { !it.date.isBefore(today) }
"${it.date.toFormattedString("dd.MM")} - ${it.title}: ${it.subject}" .map {
}.ifEmpty { return } "${it.date.toFormattedString("dd.MM")} - ${it.title}: ${it.subject}"
}
.ifEmpty { return }
val notification = MultipleNotifications( val notificationDataList = lines.map {
type = NotificationType.NEW_CONFERENCE, NotificationData(
icon = R.drawable.ic_more_conferences, title = context.getPlural(R.plurals.conference_notify_new_item_title, 1),
titleStringRes = R.plurals.conference_notify_new_item_title, content = it,
contentStringRes = R.plurals.conference_notify_new_items, intentToStart = SplashActivity.getStartIntent(context, Destination.Conference)
summaryStringRes = R.plurals.conference_number_item, )
startMenu = MainView.Section.CONFERENCE, }
lines = lines
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.conference_notify_new_item_title, lines.size),
content = context.getPlural(
R.plurals.conference_notify_new_items,
lines.size,
lines.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Conference),
type = NotificationType.NEW_CONFERENCE
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
} }

View File

@ -1,38 +1,52 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import android.content.Context import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Exam import io.github.wulkanowy.data.db.entities.Exam
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotifications import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
class NewExamNotification @Inject constructor( class NewExamNotification @Inject constructor(
@ApplicationContext private val context: Context, private val appNotificationManager: AppNotificationManager,
notificationManager: NotificationManagerCompat, @ApplicationContext private val context: Context
) : BaseNotification(context, notificationManager) { ) {
fun notify(items: List<Exam>, student: Student) { suspend fun notify(items: List<Exam>, student: Student) {
val today = LocalDate.now() val today = LocalDate.now()
val lines = items.filter { !it.date.isBefore(today) }.map { val lines = items.filter { !it.date.isBefore(today) }
"${it.date.toFormattedString("dd.MM")} - ${it.subject}: ${it.description}" .map {
}.ifEmpty { return } "${it.date.toFormattedString("dd.MM")} - ${it.subject}: ${it.description}"
}
.ifEmpty { return }
val notification = MultipleNotifications( val notificationDataList = lines.map {
type = NotificationType.NEW_EXAM, NotificationData(
icon = R.drawable.ic_main_exam, title = context.getPlural(R.plurals.exam_notify_new_item_title, 1),
titleStringRes = R.plurals.exam_notify_new_item_title, content = it,
contentStringRes = R.plurals.exam_notify_new_item_content, intentToStart = SplashActivity.getStartIntent(context, Destination.Exam),
summaryStringRes = R.plurals.exam_number_item, )
startMenu = MainView.Section.EXAM, }
lines = lines
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.exam_notify_new_item_title, lines.size),
content = context.getPlural(
R.plurals.exam_notify_new_item_content,
lines.size,
lines.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Exam),
type = NotificationType.NEW_EXAM
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
} }

View File

@ -1,66 +1,88 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import android.content.Context import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Grade import io.github.wulkanowy.data.db.entities.Grade
import io.github.wulkanowy.data.db.entities.GradeSummary import io.github.wulkanowy.data.db.entities.GradeSummary
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotifications import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import javax.inject.Inject import javax.inject.Inject
class NewGradeNotification @Inject constructor( class NewGradeNotification @Inject constructor(
@ApplicationContext private val context: Context, private val appNotificationManager: AppNotificationManager,
notificationManager: NotificationManagerCompat, @ApplicationContext private val context: Context
) : BaseNotification(context, notificationManager) { ) {
fun notifyDetails(items: List<Grade>, student: Student) { suspend fun notifyDetails(items: List<Grade>, student: Student) {
val notification = MultipleNotifications( val notificationDataList = items.map {
type = NotificationType.NEW_GRADE_DETAILS, NotificationData(
icon = R.drawable.ic_stat_grade, title = context.getPlural(R.plurals.grade_new_items, 1),
titleStringRes = R.plurals.grade_new_items, content = "${it.subject}: ${it.entry}",
contentStringRes = R.plurals.grade_notify_new_items, intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
summaryStringRes = R.plurals.grade_number_item, )
startMenu = MainView.Section.GRADE, }
lines = items.map {
"${it.subject}: ${it.entry}" val groupNotificationData = GroupNotificationData(
} notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.grade_new_items, items.size),
content = context.getPlural(R.plurals.grade_notify_new_items, items.size, items.size),
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
type = NotificationType.NEW_GRADE_DETAILS
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
fun notifyPredicted(items: List<GradeSummary>, student: Student) { suspend fun notifyPredicted(items: List<GradeSummary>, student: Student) {
val notification = MultipleNotifications( val notificationDataList = items.map {
type = NotificationType.NEW_GRADE_PREDICTED, NotificationData(
icon = R.drawable.ic_stat_grade, title = context.getPlural(R.plurals.grade_new_items_predicted, 1),
titleStringRes = R.plurals.grade_new_items_predicted, content = "${it.subject}: ${it.predictedGrade}",
contentStringRes = R.plurals.grade_notify_new_items_predicted, intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
summaryStringRes = R.plurals.grade_number_item, )
startMenu = MainView.Section.GRADE, }
lines = items.map {
"${it.subject}: ${it.predictedGrade}" val groupNotificationData = GroupNotificationData(
} notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.grade_new_items_predicted, items.size),
content = context.getPlural(
R.plurals.grade_notify_new_items_predicted,
items.size,
items.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
type = NotificationType.NEW_GRADE_PREDICTED
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
fun notifyFinal(items: List<GradeSummary>, student: Student) { suspend fun notifyFinal(items: List<GradeSummary>, student: Student) {
val notification = MultipleNotifications( val notificationDataList = items.map {
type = NotificationType.NEW_GRADE_FINAL, NotificationData(
icon = R.drawable.ic_stat_grade, title = context.getPlural(R.plurals.grade_new_items_final, 1),
titleStringRes = R.plurals.grade_new_items_final, content = "${it.subject}: ${it.finalGrade}",
contentStringRes = R.plurals.grade_notify_new_items_final, intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
summaryStringRes = R.plurals.grade_number_item, )
startMenu = MainView.Section.GRADE, }
lines = items.map {
"${it.subject}: ${it.finalGrade}" val groupNotificationData = GroupNotificationData(
} notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.grade_new_items_final, items.size),
content = context.getPlural(
R.plurals.grade_notify_new_items_final,
items.size,
items.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Grade),
type = NotificationType.NEW_GRADE_FINAL
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
} }

View File

@ -1,38 +1,52 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import android.content.Context import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Homework import io.github.wulkanowy.data.db.entities.Homework
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotifications import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import io.github.wulkanowy.utils.toFormattedString import io.github.wulkanowy.utils.toFormattedString
import java.time.LocalDate import java.time.LocalDate
import javax.inject.Inject import javax.inject.Inject
class NewHomeworkNotification @Inject constructor( class NewHomeworkNotification @Inject constructor(
@ApplicationContext private val context: Context, private val appNotificationManager: AppNotificationManager,
notificationManager: NotificationManagerCompat, @ApplicationContext private val context: Context
) : BaseNotification(context, notificationManager) { ) {
fun notify(items: List<Homework>, student: Student) { suspend fun notify(items: List<Homework>, student: Student) {
val today = LocalDate.now() val today = LocalDate.now()
val lines = items.filter { !it.date.isBefore(today) }.map { val lines = items.filter { !it.date.isBefore(today) }
"${it.date.toFormattedString("dd.MM")} - ${it.subject}: ${it.content}" .map {
}.ifEmpty { return } "${it.date.toFormattedString("dd.MM")} - ${it.subject}: ${it.content}"
}
.ifEmpty { return }
val notification = MultipleNotifications( val notificationDataList = lines.map {
NotificationData(
title = context.getPlural(R.plurals.homework_notify_new_item_title, 1),
content = it,
intentToStart = SplashActivity.getStartIntent(context, Destination.Homework),
)
}
val groupNotificationData = GroupNotificationData(
title = context.getPlural(R.plurals.homework_notify_new_item_title, lines.size),
content = context.getPlural(
R.plurals.homework_notify_new_item_content,
lines.size,
lines.size
),
intentToStart = SplashActivity.getStartIntent(context, Destination.Homework),
type = NotificationType.NEW_HOMEWORK, type = NotificationType.NEW_HOMEWORK,
icon = R.drawable.ic_more_homework, notificationDataList = notificationDataList
titleStringRes = R.plurals.homework_notify_new_item_title,
contentStringRes = R.plurals.homework_notify_new_item_content,
summaryStringRes = R.plurals.homework_number_item,
startMenu = MainView.Section.HOMEWORK,
lines = lines
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
} }

View File

@ -1,30 +1,34 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import android.content.Context import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.LuckyNumber import io.github.wulkanowy.data.db.entities.LuckyNumber
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.OneNotification import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import javax.inject.Inject import javax.inject.Inject
class NewLuckyNumberNotification @Inject constructor( class NewLuckyNumberNotification @Inject constructor(
@ApplicationContext private val context: Context, private val appNotificationManager: AppNotificationManager,
notificationManager: NotificationManagerCompat, @ApplicationContext private val context: Context
) : BaseNotification(context, notificationManager) { ) {
fun notify(item: LuckyNumber, student: Student) { suspend fun notify(item: LuckyNumber, student: Student) {
val notification = OneNotification( val notificationData = NotificationData(
type = NotificationType.NEW_LUCKY_NUMBER, title = context.getString(R.string.lucky_number_notify_new_item_title),
icon = R.drawable.ic_stat_luckynumber, content = context.getString(
titleStringRes = R.string.lucky_number_notify_new_item_title, R.string.lucky_number_notify_new_item,
contentStringRes = R.string.lucky_number_notify_new_item, item.luckyNumber.toString()
startMenu = MainView.Section.LUCKY_NUMBER, ),
contentValues = listOf(item.luckyNumber.toString()) intentToStart = SplashActivity.getStartIntent(context, Destination.LuckyNumber)
) )
sendNotification(notification, student) appNotificationManager.sendSingleNotification(
notificationData = notificationData,
notificationType = NotificationType.NEW_LUCKY_NUMBER,
student = student
)
} }
} }

View File

@ -1,33 +1,39 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import android.content.Context import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Message import io.github.wulkanowy.data.db.entities.Message
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotifications import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import javax.inject.Inject import javax.inject.Inject
class NewMessageNotification @Inject constructor( class NewMessageNotification @Inject constructor(
@ApplicationContext private val context: Context, private val appNotificationManager: AppNotificationManager,
notificationManager: NotificationManagerCompat, @ApplicationContext private val context: Context
) : BaseNotification(context, notificationManager) { ) {
fun notify(items: List<Message>, student: Student) { suspend fun notify(items: List<Message>, student: Student) {
val notification = MultipleNotifications( val notificationDataList = items.map {
type = NotificationType.NEW_MESSAGE, NotificationData(
icon = R.drawable.ic_stat_message, title = context.getPlural(R.plurals.message_new_items, 1),
titleStringRes = R.plurals.message_new_items, content = "${it.sender}: ${it.subject}",
contentStringRes = R.plurals.message_notify_new_items, intentToStart = SplashActivity.getStartIntent(context, Destination.Message),
summaryStringRes = R.plurals.message_number_item, )
startMenu = MainView.Section.MESSAGE, }
lines = items.map {
"${it.sender}: ${it.subject}" val groupNotificationData = GroupNotificationData(
} notificationDataList = notificationDataList,
title = context.getPlural(R.plurals.message_new_items, items.size),
content = context.getPlural(R.plurals.message_notify_new_items, items.size, items.size),
intentToStart = SplashActivity.getStartIntent(context, Destination.Message),
type = NotificationType.NEW_MESSAGE
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
} }

View File

@ -1,46 +1,46 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import android.content.Context import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.Note import io.github.wulkanowy.data.db.entities.Note
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotifications import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.sdk.scrapper.notes.NoteCategory import io.github.wulkanowy.sdk.scrapper.notes.NoteCategory
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import javax.inject.Inject import javax.inject.Inject
class NewNoteNotification @Inject constructor( class NewNoteNotification @Inject constructor(
@ApplicationContext private val context: Context, private val appNotificationManager: AppNotificationManager,
notificationManager: NotificationManagerCompat, @ApplicationContext private val context: Context
) : BaseNotification(context, notificationManager) { ) {
fun notify(items: List<Note>, student: Student) { suspend fun notify(items: List<Note>, student: Student) {
val notification = MultipleNotifications( val notificationDataList = items.map {
type = NotificationType.NEW_NOTE, val titleRes = when (NoteCategory.getByValue(it.categoryType)) {
icon = R.drawable.ic_stat_note,
titleStringRes = when (NoteCategory.getByValue(items.first().categoryType)) {
NoteCategory.POSITIVE -> R.plurals.praise_new_items NoteCategory.POSITIVE -> R.plurals.praise_new_items
NoteCategory.NEUTRAL -> R.plurals.neutral_note_new_items NoteCategory.NEUTRAL -> R.plurals.neutral_note_new_items
else -> R.plurals.note_new_items else -> R.plurals.note_new_items
},
contentStringRes = when (NoteCategory.getByValue(items.first().categoryType)) {
NoteCategory.POSITIVE -> R.plurals.praise_notify_new_items
NoteCategory.NEUTRAL -> R.plurals.neutral_note_notify_new_items
else -> R.plurals.note_notify_new_items
},
summaryStringRes = when (NoteCategory.getByValue(items.first().categoryType)) {
NoteCategory.POSITIVE -> R.plurals.praise_number_item
NoteCategory.NEUTRAL -> R.plurals.neutral_note_number_item
else -> R.plurals.note_number_item
},
startMenu = MainView.Section.NOTE,
lines = items.map {
"${it.teacher}: ${it.category}"
} }
NotificationData(
title = context.getPlural(titleRes, 1),
content = "${it.teacher}: ${it.category}",
intentToStart = SplashActivity.getStartIntent(context, Destination.Note),
)
}
val groupNotificationData = GroupNotificationData(
notificationDataList = notificationDataList,
intentToStart = SplashActivity.getStartIntent(context, Destination.Note),
title = context.getPlural(R.plurals.note_new_items, items.size),
content = context.getPlural(R.plurals.note_notify_new_items, items.size, items.size),
type = NotificationType.NEW_NOTE
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
} }

View File

@ -1,33 +1,54 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import android.content.Context import android.content.Context
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.data.db.entities.SchoolAnnouncement import io.github.wulkanowy.data.db.entities.SchoolAnnouncement
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.pojos.MultipleNotifications import io.github.wulkanowy.data.pojos.GroupNotificationData
import io.github.wulkanowy.ui.modules.main.MainView import io.github.wulkanowy.data.pojos.NotificationData
import io.github.wulkanowy.ui.modules.Destination
import io.github.wulkanowy.ui.modules.splash.SplashActivity
import io.github.wulkanowy.utils.getPlural
import javax.inject.Inject import javax.inject.Inject
class NewSchoolAnnouncementNotification @Inject constructor( class NewSchoolAnnouncementNotification @Inject constructor(
@ApplicationContext private val context: Context, private val appNotificationManager: AppNotificationManager,
notificationManager: NotificationManagerCompat, @ApplicationContext private val context: Context
) : BaseNotification(context, notificationManager) { ) {
fun notify(items: List<SchoolAnnouncement>, student: Student) { suspend fun notify(items: List<SchoolAnnouncement>, student: Student) {
val notification = MultipleNotifications( val notificationDataList = items.map {
NotificationData(
intentToStart = SplashActivity.getStartIntent(
context = context,
destination = Destination.SchoolAnnouncement
),
title = context.getPlural(
R.plurals.school_announcement_notify_new_item_title,
1
),
content = "${it.subject}: ${it.content}"
)
}
val groupNotificationData = GroupNotificationData(
type = NotificationType.NEW_ANNOUNCEMENT, type = NotificationType.NEW_ANNOUNCEMENT,
icon = R.drawable.ic_all_about, intentToStart = SplashActivity.getStartIntent(
titleStringRes = R.plurals.school_announcement_notify_new_item_title, context = context,
contentStringRes = R.plurals.school_announcement_notify_new_items, destination = Destination.SchoolAnnouncement
summaryStringRes = R.plurals.school_announcement_number_item, ),
startMenu = MainView.Section.SCHOOL_ANNOUNCEMENT, title = context.getPlural(
lines = items.map { R.plurals.school_announcement_notify_new_item_title,
"${it.subject}: ${it.content}" items.size
} ),
content = context.getPlural(
R.plurals.school_announcement_notify_new_items,
items.size,
items.size
),
notificationDataList = notificationDataList
) )
sendNotification(notification, student) appNotificationManager.sendMultipleNotifications(groupNotificationData, student)
} }
} }

View File

@ -1,6 +1,8 @@
package io.github.wulkanowy.services.sync.notifications package io.github.wulkanowy.services.sync.notifications
import io.github.wulkanowy.R
import io.github.wulkanowy.services.sync.channels.LuckyNumberChannel import io.github.wulkanowy.services.sync.channels.LuckyNumberChannel
import io.github.wulkanowy.services.sync.channels.NewAttendanceChannel
import io.github.wulkanowy.services.sync.channels.NewConferencesChannel import io.github.wulkanowy.services.sync.channels.NewConferencesChannel
import io.github.wulkanowy.services.sync.channels.NewExamChannel import io.github.wulkanowy.services.sync.channels.NewExamChannel
import io.github.wulkanowy.services.sync.channels.NewGradesChannel import io.github.wulkanowy.services.sync.channels.NewGradesChannel
@ -8,16 +10,77 @@ import io.github.wulkanowy.services.sync.channels.NewHomeworkChannel
import io.github.wulkanowy.services.sync.channels.NewMessagesChannel import io.github.wulkanowy.services.sync.channels.NewMessagesChannel
import io.github.wulkanowy.services.sync.channels.NewNotesChannel import io.github.wulkanowy.services.sync.channels.NewNotesChannel
import io.github.wulkanowy.services.sync.channels.NewSchoolAnnouncementsChannel import io.github.wulkanowy.services.sync.channels.NewSchoolAnnouncementsChannel
import io.github.wulkanowy.services.sync.channels.PushChannel
import io.github.wulkanowy.services.sync.channels.TimetableChangeChannel
enum class NotificationType(val group: String, val channel: String) { enum class NotificationType(
NEW_CONFERENCE("new_conferences_group", NewConferencesChannel.CHANNEL_ID), val group: String?,
NEW_EXAM("new_exam_group", NewExamChannel.CHANNEL_ID), val channel: String,
NEW_GRADE_DETAILS("new_grade_details_group", NewGradesChannel.CHANNEL_ID), val icon: Int
NEW_GRADE_PREDICTED("new_grade_predicted_group", NewGradesChannel.CHANNEL_ID), ) {
NEW_GRADE_FINAL("new_grade_final_group", NewGradesChannel.CHANNEL_ID), NEW_CONFERENCE(
NEW_HOMEWORK("new_homework_group", NewHomeworkChannel.CHANNEL_ID), group = "new_conferences_group",
NEW_LUCKY_NUMBER("lucky_number_group", LuckyNumberChannel.CHANNEL_ID), channel = NewConferencesChannel.CHANNEL_ID,
NEW_MESSAGE("new_message_group", NewMessagesChannel.CHANNEL_ID), icon = R.drawable.ic_more_conferences,
NEW_NOTE("new_notes_group", NewNotesChannel.CHANNEL_ID), ),
NEW_ANNOUNCEMENT("new_school_announcements_group", NewSchoolAnnouncementsChannel.CHANNEL_ID), NEW_EXAM(
group = "new_exam_group",
channel = NewExamChannel.CHANNEL_ID,
icon = R.drawable.ic_main_exam
),
NEW_GRADE_DETAILS(
group = "new_grade_details_group",
channel = NewGradesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_grade,
),
NEW_GRADE_PREDICTED(
group = "new_grade_predicted_group",
channel = NewGradesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_grade,
),
NEW_GRADE_FINAL(
group = "new_grade_final_group",
channel = NewGradesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_grade,
),
NEW_HOMEWORK(
group = "new_homework_group",
channel = NewHomeworkChannel.CHANNEL_ID,
icon = R.drawable.ic_more_homework,
),
NEW_LUCKY_NUMBER(
group = null,
channel = LuckyNumberChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_luckynumber,
),
NEW_MESSAGE(
group = "new_message_group",
channel = NewMessagesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_message,
),
NEW_NOTE(
group = "new_notes_group",
channel = NewNotesChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_note
),
NEW_ANNOUNCEMENT(
group = "new_school_announcements_group",
channel = NewSchoolAnnouncementsChannel.CHANNEL_ID,
icon = R.drawable.ic_all_about
),
CHANGE_TIMETABLE(
group = "change_timetable_group",
channel = TimetableChangeChannel.CHANNEL_ID,
icon = R.drawable.ic_main_timetable
),
NEW_ATTENDANCE(
group = "new_attendance_group",
channel = NewAttendanceChannel.CHANNEL_ID,
icon = R.drawable.ic_main_attendance
),
PUSH(
group = null,
channel = PushChannel.CHANNEL_ID,
icon = R.drawable.ic_stat_all
)
} }

View File

@ -3,15 +3,40 @@ package io.github.wulkanowy.services.sync.works
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.AttendanceRepository import io.github.wulkanowy.data.repositories.AttendanceRepository
import io.github.wulkanowy.utils.monday import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.utils.sunday import io.github.wulkanowy.services.sync.notifications.NewAttendanceNotification
import io.github.wulkanowy.utils.previousOrSameSchoolDay
import io.github.wulkanowy.utils.waitForResult import io.github.wulkanowy.utils.waitForResult
import kotlinx.coroutines.flow.first
import java.time.LocalDate.now import java.time.LocalDate.now
import javax.inject.Inject import javax.inject.Inject
class AttendanceWork @Inject constructor(private val attendanceRepository: AttendanceRepository) : Work { class AttendanceWork @Inject constructor(
private val attendanceRepository: AttendanceRepository,
private val newAttendanceNotification: NewAttendanceNotification,
private val preferencesRepository: PreferencesRepository
) : Work {
override suspend fun doWork(student: Student, semester: Semester) { override suspend fun doWork(student: Student, semester: Semester) {
attendanceRepository.getAttendance(student, semester, now().monday, now().sunday, true).waitForResult() attendanceRepository.getAttendance(
student = student,
semester = semester,
start = now().previousOrSameSchoolDay,
end = now().previousOrSameSchoolDay,
forceRefresh = true,
notify = preferencesRepository.isNotificationsEnable
)
.waitForResult()
attendanceRepository.getAttendanceFromDatabase(semester, now().minusDays(7), now())
.first()
.filterNot { it.isNotified }
.let {
if (it.isNotEmpty()) newAttendanceNotification.notify(it, student)
attendanceRepository.updateTimetable(it.onEach { attendance ->
attendance.isNotified = true
})
}
} }
} }

View File

@ -5,8 +5,7 @@ import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.HomeworkRepository import io.github.wulkanowy.data.repositories.HomeworkRepository
import io.github.wulkanowy.data.repositories.PreferencesRepository import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.services.sync.notifications.NewHomeworkNotification import io.github.wulkanowy.services.sync.notifications.NewHomeworkNotification
import io.github.wulkanowy.utils.monday import io.github.wulkanowy.utils.nextOrSameSchoolDay
import io.github.wulkanowy.utils.sunday
import io.github.wulkanowy.utils.waitForResult import io.github.wulkanowy.utils.waitForResult
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import java.time.LocalDate.now import java.time.LocalDate.now
@ -22,13 +21,13 @@ class HomeworkWork @Inject constructor(
homeworkRepository.getHomework( homeworkRepository.getHomework(
student = student, student = student,
semester = semester, semester = semester,
start = now().monday, start = now().nextOrSameSchoolDay,
end = now().sunday, end = now().nextOrSameSchoolDay,
forceRefresh = true, forceRefresh = true,
notify = preferencesRepository.isNotificationsEnable notify = preferencesRepository.isNotificationsEnable
).waitForResult() ).waitForResult()
homeworkRepository.getHomeworkFromDatabase(semester, now().monday, now().sunday).first() homeworkRepository.getHomeworkFromDatabase(semester, now(), now().plusDays(7)).first()
.filter { !it.isNotified }.let { .filter { !it.isNotified }.let {
if (it.isNotEmpty()) newHomeworkNotification.notify(it, student) if (it.isNotEmpty()) newHomeworkNotification.notify(it, student)

View File

@ -2,18 +2,41 @@ package io.github.wulkanowy.services.sync.works
import io.github.wulkanowy.data.db.entities.Semester import io.github.wulkanowy.data.db.entities.Semester
import io.github.wulkanowy.data.db.entities.Student import io.github.wulkanowy.data.db.entities.Student
import io.github.wulkanowy.data.repositories.PreferencesRepository
import io.github.wulkanowy.data.repositories.TimetableRepository import io.github.wulkanowy.data.repositories.TimetableRepository
import io.github.wulkanowy.utils.monday import io.github.wulkanowy.services.sync.notifications.ChangeTimetableNotification
import io.github.wulkanowy.utils.sunday import io.github.wulkanowy.utils.nextOrSameSchoolDay
import io.github.wulkanowy.utils.waitForResult import io.github.wulkanowy.utils.waitForResult
import kotlinx.coroutines.flow.first
import java.time.LocalDate.now import java.time.LocalDate.now
import javax.inject.Inject import javax.inject.Inject
class TimetableWork @Inject constructor( class TimetableWork @Inject constructor(
private val timetableRepository: TimetableRepository private val timetableRepository: TimetableRepository,
private val changeTimetableNotification: ChangeTimetableNotification,
private val preferencesRepository: PreferencesRepository
) : Work { ) : Work {
override suspend fun doWork(student: Student, semester: Semester) { override suspend fun doWork(student: Student, semester: Semester) {
timetableRepository.getTimetable(student, semester, now().monday, now().sunday, true).waitForResult() timetableRepository.getTimetable(
student = student,
semester = semester,
start = now().nextOrSameSchoolDay,
end = now().nextOrSameSchoolDay,
forceRefresh = true,
notify = preferencesRepository.isNotificationsEnable
)
.waitForResult()
timetableRepository.getTimetableFromDatabase(semester, now(), now().plusDays(7))
.first()
.filterNot { it.isNotified }
.let {
if (it.isNotEmpty()) changeTimetableNotification.notify(it, student)
timetableRepository.updateTimetable(it.onEach { timetable ->
timetable.isNotified = true
})
}
} }
} }

View File

@ -1,14 +1,11 @@
package io.github.wulkanowy.ui.base package io.github.wulkanowy.ui.base
import android.app.ActivityManager import android.app.ActivityManager
import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.snackbar.Snackbar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar.LENGTH_LONG
@ -40,7 +37,6 @@ abstract class BaseActivity<T : BasePresenter<out BaseView>, VB : ViewBinding> :
themeManager.applyActivityTheme(this) themeManager.applyActivityTheme(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleLogger, true) supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleLogger, true)
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
setTaskDescription( setTaskDescription(
@ -83,8 +79,8 @@ abstract class BaseActivity<T : BasePresenter<out BaseView>, VB : ViewBinding> :
} }
override fun openClearLoginView() { override fun openClearLoginView() {
startActivity(LoginActivity.getStartIntent(this) startActivity(LoginActivity.getStartIntent(this))
.apply { addFlags(FLAG_ACTIVITY_CLEAR_TASK or FLAG_ACTIVITY_NEW_TASK) }) finishAffinity()
} }
override fun onDestroy() { override fun onDestroy() {

View File

@ -2,32 +2,33 @@ package io.github.wulkanowy.ui.base
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentPagerAdapter import androidx.lifecycle.Lifecycle
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
//TODO Use ViewPager2 class BaseFragmentPagerAdapter(
class BaseFragmentPagerAdapter(private val fragmentManager: FragmentManager) : private val fragmentManager: FragmentManager,
FragmentPagerAdapter(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { private val pagesCount: Int,
lifecycle: Lifecycle,
) : FragmentStateAdapter(fragmentManager, lifecycle), TabLayoutMediator.TabConfigurationStrategy {
private val pages = mutableMapOf<Fragment, String?>() lateinit var itemFactory: (position: Int) -> Fragment
var titleFactory: (position: Int) -> String? = { "" }
var containerId = 0 var containerId = 0
fun getFragmentInstance(position: Int): Fragment? { fun getFragmentInstance(position: Int): Fragment? {
require(containerId != 0) { "Container id is 0" } require(containerId != 0) { "Container id is 0" }
return fragmentManager.findFragmentByTag("android:switcher:$containerId:$position") return fragmentManager.findFragmentByTag("f$position")
} }
fun addFragments(fragments: List<Fragment>) { override fun createFragment(position: Int): Fragment = itemFactory(position)
fragments.forEach { pages[it] = null }
override fun getItemCount() = pagesCount
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.text = titleFactory(position)
} }
fun addFragmentsWithTitle(pages: Map<Fragment, String>) {
this.pages.putAll(pages)
}
override fun getItem(position: Int) = pages.keys.elementAt(position)
override fun getCount() = pages.size
override fun getPageTitle(position: Int) = pages.values.elementAt(position)
} }

View File

@ -6,29 +6,27 @@ import io.github.wulkanowy.utils.flowWithResource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import timber.log.Timber import timber.log.Timber
import kotlin.coroutines.CoroutineContext
open class BasePresenter<T : BaseView>( open class BasePresenter<T : BaseView>(
protected val errorHandler: ErrorHandler, protected val errorHandler: ErrorHandler,
protected val studentRepository: StudentRepository protected val studentRepository: StudentRepository
) : CoroutineScope { ) {
private val job = SupervisorJob()
private var job = Job() protected val presenterScope = CoroutineScope(job + Dispatchers.Main)
private val jobs = mutableMapOf<String, Job>() private val childrenJobs = mutableMapOf<String, Job>()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
var view: T? = null var view: T? = null
open fun onAttachView(view: T) { open fun onAttachView(view: T) {
job = Job()
this.view = view this.view = view
errorHandler.apply { errorHandler.apply {
showErrorMessage = view::showError showErrorMessage = view::showError
@ -64,22 +62,22 @@ open class BasePresenter<T : BaseView>(
} }
fun <T> Flow<T>.launch(individualJobTag: String = "load"): Job { fun <T> Flow<T>.launch(individualJobTag: String = "load"): Job {
jobs[individualJobTag]?.cancel() childrenJobs[individualJobTag]?.cancel()
val job = catch { errorHandler.dispatch(it) }.launchIn(this@BasePresenter) val job = catch { errorHandler.dispatch(it) }.launchIn(presenterScope)
jobs[individualJobTag] = job childrenJobs[individualJobTag] = job
Timber.d("Job $individualJobTag launched in ${this@BasePresenter.javaClass.simpleName}: $job") Timber.d("Job $individualJobTag launched in ${this@BasePresenter.javaClass.simpleName}: $job")
return job return job
} }
fun cancelJobs(vararg names: String) { fun cancelJobs(vararg names: String) {
names.forEach { names.forEach {
jobs[it]?.cancel() childrenJobs[it]?.cancel()
} }
} }
open fun onDetachView() { open fun onDetachView() {
view = null job.cancelChildren()
job.cancel()
errorHandler.clear() errorHandler.clear()
view = null
} }
} }

View File

@ -11,6 +11,7 @@ import android.widget.Toast
import android.widget.Toast.LENGTH_LONG import android.widget.Toast.LENGTH_LONG
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import androidx.core.view.isGone
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import io.github.wulkanowy.R import io.github.wulkanowy.R
import io.github.wulkanowy.databinding.DialogErrorBinding import io.github.wulkanowy.databinding.DialogErrorBinding
@ -24,8 +25,6 @@ import io.github.wulkanowy.utils.openEmailClient
import io.github.wulkanowy.utils.openInternetBrowser import io.github.wulkanowy.utils.openInternetBrowser
import okhttp3.internal.http2.StreamResetException import okhttp3.internal.http2.StreamResetException
import java.io.InterruptedIOException import java.io.InterruptedIOException
import java.io.PrintWriter
import java.io.StringWriter
import java.net.ConnectException import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
@ -64,26 +63,26 @@ class ErrorDialog : BaseDialogFragment<DialogErrorBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
val stringWriter = StringWriter().apply { val errorStacktrace = error.stackTraceToString()
error.printStackTrace(PrintWriter(this))
}
with(binding) { with(binding) {
errorDialogContent.text = stringWriter.toString() errorDialogContent.text = errorStacktrace.replace(": ${error.localizedMessage}", "")
with(errorDialogHorizontalScroll) { with(errorDialogHorizontalScroll) {
post { fullScroll(HorizontalScrollView.FOCUS_LEFT) } post { fullScroll(HorizontalScrollView.FOCUS_LEFT) }
} }
errorDialogCopy.setOnClickListener { errorDialogCopy.setOnClickListener {
val clip = ClipData.newPlainText("wulkanowy", stringWriter.toString()) val clip = ClipData.newPlainText("Error details", errorStacktrace)
activity?.getSystemService<ClipboardManager>()?.setPrimaryClip(clip) activity?.getSystemService<ClipboardManager>()?.setPrimaryClip(clip)
Toast.makeText(context, R.string.all_copied, LENGTH_LONG).show() Toast.makeText(context, R.string.all_copied, LENGTH_LONG).show()
} }
errorDialogCancel.setOnClickListener { dismiss() } errorDialogCancel.setOnClickListener { dismiss() }
errorDialogReport.setOnClickListener { errorDialogReport.setOnClickListener {
openConfirmDialog { openEmailClient(stringWriter.toString()) } openConfirmDialog { openEmailClient(errorStacktrace) }
} }
errorDialogMessage.text = resources.getString(error) errorDialogHumanizedMessage.text = resources.getString(error)
errorDialogErrorMessage.text = error.localizedMessage
errorDialogErrorMessage.isGone = error.localizedMessage.isNullOrBlank()
errorDialogReport.isEnabled = when (error) { errorDialogReport.isEnabled = when (error) {
is UnknownHostException, is UnknownHostException,
is InterruptedIOException, is InterruptedIOException,

View File

@ -1,6 +1,7 @@
package io.github.wulkanowy.ui.base package io.github.wulkanowy.ui.base
import android.content.res.Resources import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.github.wulkanowy.data.exceptions.NoCurrentStudentException import io.github.wulkanowy.data.exceptions.NoCurrentStudentException
import io.github.wulkanowy.sdk.scrapper.login.BadCredentialsException import io.github.wulkanowy.sdk.scrapper.login.BadCredentialsException
import io.github.wulkanowy.sdk.scrapper.login.PasswordChangeRequiredException import io.github.wulkanowy.sdk.scrapper.login.PasswordChangeRequiredException
@ -9,7 +10,7 @@ import io.github.wulkanowy.utils.security.ScramblerException
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
open class ErrorHandler @Inject constructor(protected val resources: Resources) { open class ErrorHandler @Inject constructor(@ApplicationContext protected val context: Context) {
var showErrorMessage: (String, Throwable) -> Unit = { _, _ -> } var showErrorMessage: (String, Throwable) -> Unit = { _, _ -> }
@ -25,7 +26,7 @@ open class ErrorHandler @Inject constructor(protected val resources: Resources)
} }
protected open fun proceed(error: Throwable) { protected open fun proceed(error: Throwable) {
showErrorMessage(resources.getString(error), error) showErrorMessage(context.resources.getString(error), error)
when (error) { when (error) {
is PasswordChangeRequiredException -> onPasswordChangeRequired(error.redirectUrl) is PasswordChangeRequiredException -> onPasswordChangeRequired(error.redirectUrl)
is ScramblerException, is BadCredentialsException -> onSessionExpired() is ScramblerException, is BadCredentialsException -> onSessionExpired()

View File

@ -41,14 +41,15 @@ class ThemeManager @Inject constructor(private val preferencesRepository: Prefer
) )
} }
private fun isThemeApplicable(activity: AppCompatActivity): Boolean { private fun isThemeApplicable(activity: AppCompatActivity) =
return activity.packageManager activity.packageManager
.getPackageInfo(activity.packageName, GET_ACTIVITIES) .getPackageInfo(activity.packageName, GET_ACTIVITIES)
.activities.singleOrNull { it.name == activity::class.java.canonicalName } .activities
?.theme.let { .singleOrNull { it.name == activity::class.java.canonicalName }
?.theme
.let {
it == R.style.WulkanowyTheme_Black || it == R.style.WulkanowyTheme_NoActionBar it == R.style.WulkanowyTheme_Black || it == R.style.WulkanowyTheme_NoActionBar
|| it == R.style.WulkanowyTheme_Login || it == R.style.WulkanowyTheme_Login_Black || it == R.style.WulkanowyTheme_Login || it == R.style.WulkanowyTheme_Login_Black
|| it == R.style.WulkanowyTheme_MessageSend || it == R.style.WulkanowyTheme_MessageSend_Black || it == R.style.WulkanowyTheme_MessageSend || it == R.style.WulkanowyTheme_MessageSend_Black
} }
}
} }

View File

@ -0,0 +1,136 @@
package io.github.wulkanowy.ui.modules
import androidx.fragment.app.Fragment
import io.github.wulkanowy.ui.modules.attendance.AttendanceFragment
import io.github.wulkanowy.ui.modules.conference.ConferenceFragment
import io.github.wulkanowy.ui.modules.dashboard.DashboardFragment
import io.github.wulkanowy.ui.modules.exam.ExamFragment
import io.github.wulkanowy.ui.modules.grade.GradeFragment
import io.github.wulkanowy.ui.modules.homework.HomeworkFragment
import io.github.wulkanowy.ui.modules.luckynumber.LuckyNumberFragment
import io.github.wulkanowy.ui.modules.message.MessageFragment
import io.github.wulkanowy.ui.modules.more.MoreFragment
import io.github.wulkanowy.ui.modules.note.NoteFragment
import io.github.wulkanowy.ui.modules.schoolandteachers.school.SchoolFragment
import io.github.wulkanowy.ui.modules.schoolannouncement.SchoolAnnouncementFragment
import io.github.wulkanowy.ui.modules.timetable.TimetableFragment
import java.io.Serializable
import java.time.LocalDate
sealed interface Destination : Serializable {
/*
Type in children classes have to be as getter to avoid null in enums
https://stackoverflow.com/questions/68866453/kotlin-enum-val-is-returning-null-despite-being-set-at-compile-time
*/
val type: Type
val fragment: Fragment
enum class Type(val defaultDestination: Destination) {
DASHBOARD(Dashboard),
GRADE(Grade),
ATTENDANCE(Attendance),
EXAM(Exam),
TIMETABLE(Timetable()),
HOMEWORK(Homework),
NOTE(Note),
CONFERENCE(Conference),
SCHOOL_ANNOUNCEMENT(SchoolAnnouncement),
SCHOOL(School),
LUCKY_NUMBER(More),
MORE(More),
MESSAGE(Message);
}
object Dashboard : Destination {
override val type get() = Type.DASHBOARD
override val fragment get() = DashboardFragment.newInstance()
}
object Grade : Destination {
override val type get() = Type.GRADE
override val fragment get() = GradeFragment.newInstance()
}
object Attendance : Destination {
override val type get() = Type.ATTENDANCE
override val fragment get() = AttendanceFragment.newInstance()
}
object Exam : Destination {
override val type get() = Type.EXAM
override val fragment get() = ExamFragment.newInstance()
}
data class Timetable(val date: LocalDate? = null) : Destination {
override val type get() = Type.TIMETABLE
override val fragment get() = TimetableFragment.newInstance(date)
}
object Homework : Destination {
override val type get() = Type.HOMEWORK
override val fragment get() = HomeworkFragment.newInstance()
}
object Note : Destination {
override val type get() = Type.NOTE
override val fragment get() = NoteFragment.newInstance()
}
object Conference : Destination {
override val type get() = Type.CONFERENCE
override val fragment get() = ConferenceFragment.newInstance()
}
object SchoolAnnouncement : Destination {
override val type get() = Type.SCHOOL_ANNOUNCEMENT
override val fragment get() = SchoolAnnouncementFragment.newInstance()
}
object School : Destination {
override val type get() = Type.SCHOOL
override val fragment get() = SchoolFragment.newInstance()
}
object LuckyNumber : Destination {
override val type get() = Type.LUCKY_NUMBER
override val fragment get() = LuckyNumberFragment.newInstance()
}
object More : Destination {
override val type get() = Type.MORE
override val fragment get() = MoreFragment.newInstance()
}
object Message : Destination {
override val type get() = Type.MESSAGE
override val fragment get() = MessageFragment.newInstance()
}
}

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