Compare commits
336 Commits
8f28fa328c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a42884d20 | |||
| 3fb0fd55de | |||
| 4c06daf02f | |||
| 3d07b2b2ce | |||
| 9a45c5c456 | |||
|
|
858d558cc3 | ||
|
|
76f345cd6b | ||
|
|
e211d64ffb | ||
|
|
c67ce7472a | ||
|
|
2b9ac14d6e | ||
|
|
eea10a4878 | ||
|
|
1ef6ae6e87 | ||
|
|
141431f378 | ||
|
|
f0114e3976 | ||
|
|
414a1ad254 | ||
|
|
d858c38bb1 | ||
|
|
87e3331428 | ||
|
|
1d912abc7d | ||
| 3a2cdfce05 | |||
| c5ba318c3b | |||
| fe9a2b36f1 | |||
| 80b05c1500 | |||
| 2ed6a04bf5 | |||
| 2d5ff89cad | |||
| 16b11564cc | |||
| 1ca975f0bd | |||
| 7985a11f99 | |||
| 2cf95375cb | |||
| 163b3a3135 | |||
| e839b8fa70 | |||
| c6378c9d46 | |||
| 51456b3b6e | |||
|
|
28f8f1d6f8 | ||
| d51c9d6411 | |||
| 4ef2f479d6 | |||
| d81d261c52 | |||
| 5baec2e65f | |||
| 48ea97ecef | |||
|
|
1154997531 | ||
|
|
dde05ce1db | ||
| 99ab6c07f7 | |||
|
|
40c6b83627 | ||
|
|
f4f6ddac72 | ||
|
|
938f2a56c6 | ||
|
|
e1b571afa0 | ||
|
|
729c281f1e | ||
|
|
a61a41e677 | ||
|
|
e8f1f18725 | ||
|
|
613b66bc22 | ||
|
|
1807d96895 | ||
|
|
e06d2168c0 | ||
|
|
91603923bc | ||
|
|
67e9ded2ed | ||
|
|
e9aaa26e26 | ||
|
|
56f15a681c | ||
|
|
178ebf7271 | ||
|
|
70c1397262 | ||
|
|
a334901f84 | ||
|
|
ddba6ad888 | ||
|
|
780aaee1dd | ||
|
|
09c408a6be | ||
|
|
7847878551 | ||
|
|
09ef386eab | ||
|
|
c19dd2f7eb | ||
|
|
39d4f49e99 | ||
|
|
bbdbf406d4 | ||
|
|
b17509f9db | ||
|
|
9ec164daac | ||
|
|
08f691d559 | ||
|
|
ad9e2c6676 | ||
|
|
55dbf12a2f | ||
|
|
4fc13efc52 | ||
|
|
5f16128bb0 | ||
|
|
4c8d73aa06 | ||
|
|
e4629d8489 | ||
|
|
f806dd8143 | ||
|
|
f653dc9fb4 | ||
|
|
06249bfc3d | ||
|
|
607f49bafc | ||
|
|
251e8de446 | ||
|
|
21845f89a8 | ||
|
|
25b1d06c06 | ||
|
|
dd20aa6138 | ||
|
|
9aa84c6fa1 | ||
|
|
6c75acad66 | ||
|
|
6311cafd51 | ||
|
|
1de32a35a4 | ||
|
|
f8a69a7561 | ||
|
|
f73c1127e7 | ||
|
|
a369e4ab14 | ||
|
|
8b04b77327 | ||
|
|
d56db4fe3a | ||
|
|
e7c0a95638 | ||
|
|
fa66d9af14 | ||
|
|
90c334d859 | ||
|
|
cc9ebcf6b0 | ||
|
|
ac914aa02e | ||
|
|
061f803f93 | ||
|
|
57c5e17b1c | ||
|
|
fa6f5390b1 | ||
|
|
21ff8f082f | ||
|
|
3b09d072f0 | ||
|
|
84f8e75694 | ||
|
|
1a05cce49b | ||
|
|
e732168a1b | ||
|
|
68035140b9 | ||
|
|
e004b7ee35 | ||
|
|
34718ed8ae | ||
|
|
03a5b5e767 | ||
|
|
d30dc67626 | ||
|
|
d0a517ae1e | ||
| 32a6452c1b | |||
| 579bf6bcad | |||
| 4ecf0d1a3f | |||
| 0675a5109a | |||
| bfb06d3e5f | |||
| 71b4b6aec7 | |||
| be6d28cfe3 | |||
| 8cfca13d1a | |||
| 632bb81408 | |||
|
|
f953dbd49f | ||
|
|
01e7e559ba | ||
| b6bfef7715 | |||
| bf75f72bcd | |||
|
|
b0cb020d76 | ||
|
|
4239ffa00c | ||
|
|
427b6f8086 | ||
|
|
fbd2351073 | ||
|
|
150e90d19b | ||
|
|
a6cf8b13c6 | ||
|
|
d8ae8471ab | ||
|
|
0e65f8e68c | ||
|
|
3912ad593b | ||
|
|
1f6eaaeccc | ||
| 40f188c2e2 | |||
| 0aae3801b7 | |||
| 26902b7616 | |||
| ffaf2c77e0 | |||
| 23f7f7cd48 | |||
| c2879766eb | |||
| 01cc08d5f3 | |||
| c386e1194b | |||
| 67ed4e03eb | |||
| c7d1f80e79 | |||
| 7531e58114 | |||
| c1e329cb5a | |||
| dad52bf20e | |||
| 5fb6d03d9c | |||
| 1ef757d10f | |||
| 444abb83c2 | |||
| e835dcf6b1 | |||
| e61a19fbbf | |||
| a937b854cd | |||
| 6d8f17ac00 | |||
| 78dd4239cc | |||
| 39c5ca357e | |||
| 8249dbf03e | |||
| c4e30ee4a6 | |||
| 1291d20e55 | |||
| fb8d57c0ee | |||
| e79de0326c | |||
| de335af3c1 | |||
| 4be0bcd813 | |||
|
|
b3f46d8e84 | ||
|
|
e97246ee55 | ||
|
|
159fb73919 | ||
| 7c35a5675c | |||
| 45adddb7f7 | |||
|
|
4aad2bb292 | ||
|
|
4c9eca217e | ||
|
|
ec6700e1cb | ||
|
|
b55595108f | ||
|
|
8b3ab4a3a9 | ||
|
|
046b7926c4 | ||
|
|
5626466107 | ||
|
|
6c674bd596 | ||
|
|
9465a2b2a7 | ||
| 484d8cf4cb | |||
| 863f9c8077 | |||
| a8983074dd | |||
| e031c18ecb | |||
| ba075c3b14 | |||
| 32936c2aa5 | |||
|
|
ad75a80805 | ||
|
|
0ce7db23de | ||
|
|
0317f47b88 | ||
|
|
5a2616bf71 | ||
|
|
9753717a25 | ||
|
|
cf4e27ad30 | ||
| d96c4b66bb | |||
| 68ddffd808 | |||
| 483c8de0c0 | |||
| 4850923305 | |||
| 489a3a1d24 | |||
| 4b0bf5a22d | |||
| e07b0264b8 | |||
| 32d8481217 | |||
| d0c3938510 | |||
| 636c2ea68d | |||
| b986d8b660 | |||
| 613db488b1 | |||
| 5d5c3c4c6f | |||
| 9f36569d2a | |||
| 9fc73e3c5c | |||
| bd53ba6c9b | |||
| 8d95c71fae | |||
| befaa45cdf | |||
| 5570f73cb4 | |||
| 59b470a64c | |||
| 4811519ced | |||
| 3580dc2ef8 | |||
| eb1312398d | |||
| a70457528b | |||
| 6bbdbadac4 | |||
| 911a1970e4 | |||
| 65ab5caa69 | |||
| a9624df915 | |||
| 52f6ebcfd3 | |||
| 635fdd5497 | |||
| a11f118861 | |||
| 5c205a9844 | |||
| f620bfe76c | |||
| b99051dbfc | |||
| c58f34f499 | |||
| 1e7dceb995 | |||
| 022915378e | |||
| 589e722310 | |||
| 12cce27e9d | |||
| 826312b503 | |||
| e071fc15d1 | |||
| 94b819ffbd | |||
| 299a769f74 | |||
| 4abf995fde | |||
| e261f73c30 | |||
| fc9907f33d | |||
| 2d2c2fbef9 | |||
|
|
d5c3d02dfa | ||
|
|
40a1e8f459 | ||
| 0c4bc4cd40 | |||
| 28fb054571 | |||
| b0c8f1f4b3 | |||
| 2ea0549258 | |||
| c0ea4fde7a | |||
| d56422ba0d | |||
| 3fa00fa6b6 | |||
| ef14b9dc0e | |||
|
|
9238869568 | ||
| 6f04f9e9b8 | |||
| b6c8bb3267 | |||
| 5ef21222aa | |||
| c0a8c696a3 | |||
| f4a7fe7923 | |||
| c2f3af7be1 | |||
| 3440ea2eef | |||
| 34475f35b0 | |||
| 091647dbe4 | |||
| 36ca357392 | |||
| dbbc119fd5 | |||
| 448be1ae10 | |||
| 23beb6e31f | |||
| c122eb4ff9 | |||
| 8568aa5678 | |||
| 12dea89cd4 | |||
| 67965610ce | |||
| 7cede065c3 | |||
| ae4862a653 | |||
| 30f493ddc4 | |||
| 50eb96377e | |||
| 61c795e362 | |||
| 7c65a696aa | |||
| a341833441 | |||
| 8bf3cc72be | |||
| 33028cdf38 | |||
| f01b9d4fd5 | |||
| 41ff65d50b | |||
| ae9ef1603a | |||
| 1d8d341f2d | |||
| 633926bf2d | |||
| a6344e42fa | |||
| 9cb2265a97 | |||
| 971a35b738 | |||
| 69ee75966d | |||
| 177bf3bf38 | |||
| 5b794b199c | |||
| a000481cd9 | |||
| ae79a44df9 | |||
|
|
70213e376c | ||
|
|
146124228a | ||
|
|
9a99a6869a | ||
|
|
61953b68d2 | ||
|
|
69dde9281d | ||
|
|
86c7641c60 | ||
|
|
71f1412164 | ||
|
|
7c344be550 | ||
|
|
e620f3e564 | ||
|
|
2719e6bf77 | ||
|
|
55de7d1645 | ||
|
|
0b78712e64 | ||
|
|
6c67d22fb8 | ||
|
|
5403d1324d | ||
|
|
a071cafacd | ||
|
|
dbf0d18e5c | ||
|
|
12e3fa5bff | ||
|
|
72cfa9b5eb | ||
|
|
51831e94e4 | ||
|
|
aa8b3d5e16 | ||
|
|
b83cbef7fc | ||
|
|
35e1e2c6ab | ||
|
|
874f5d4297 | ||
|
|
71ef509021 | ||
|
|
16b7d2f70a | ||
|
|
402067d624 | ||
|
|
f76b5fbcca | ||
|
|
d395529282 | ||
|
|
1038b49092 | ||
|
|
d06a3dee69 | ||
|
|
d617efec80 | ||
|
|
beb4127ef8 | ||
|
|
1f57281004 | ||
|
|
f866052905 | ||
|
|
deb013a30e | ||
|
|
8a3f77d565 | ||
|
|
1ea46ede0a | ||
|
|
79f59b6938 | ||
|
|
e2b98125fc | ||
|
|
8c877e43db | ||
|
|
02acf0e59b | ||
|
|
04870205f9 | ||
|
|
bcc95ed0af | ||
| 88fd491281 | |||
|
|
9a435235b6 | ||
|
|
e583c77a7e | ||
|
|
8dbcc1e2a9 | ||
|
|
f631d52d5a | ||
|
|
768d0904a8 | ||
|
|
c21ff3e15f |
20
.gitmodules
vendored
@@ -1,18 +1,6 @@
|
||||
[submodule "firka/vendor/isar_generator"]
|
||||
path = firka/vendor/isar_generator
|
||||
url = https://git.qwit.cloud/firka/isar_generator
|
||||
[submodule "firka/vendor/isar"]
|
||||
path = firka/vendor/isar
|
||||
url = https://git.qwit.cloud/firka/isar
|
||||
[submodule "firka/vendor/isar_flutter_libs"]
|
||||
path = firka/vendor/isar_flutter_libs
|
||||
url = https://git.qwit.cloud/firka/isar_flutter_libs
|
||||
[submodule "firka/lib/l10n"]
|
||||
path = firka/lib/l10n
|
||||
url = https://github.com/QwIT-Development/firka-localization
|
||||
[submodule "firka_wear/vendor/wear_plus"]
|
||||
path = firka_wear/vendor/wear_plus
|
||||
url = https://git.firka.app/firka/wear_plus
|
||||
[submodule "firka/android/app/src/main/java/org/brotli"]
|
||||
path = firka/android/app/src/main/java/org/brotli
|
||||
url = https://git.firka.app/firka/org_brotli
|
||||
url = https://git.firka.app/firka/firka-localization
|
||||
[submodule "firka_wear/lib/l10n"]
|
||||
path = firka_wear/lib/l10n
|
||||
url = https://git.firka.app/firka/firka-localization
|
||||
|
||||
@@ -1,35 +1,19 @@
|
||||
# Flutter telepítése
|
||||
|
||||
A firka androidra való lebuildeléséhez kötelező a saját Flutter fork használata, illetve minden más fajta --release buildhez is.
|
||||
|
||||
A Flutter telepítéséhez a dokumentáció [itt](https://docs.flutter.dev/get-started/install) található.
|
||||
|
||||
A Flutter zip letöltése helyett a custom engine-t cloneold le ([https://git.firka.app/firka/flutter/](https://git.firka.app/firka/flutter/))
|
||||
|
||||
# Brotli
|
||||
|
||||
A firka brotlival compresseli a libflutter-t buildelés közben ezért szükséges a projekt
|
||||
buildeléséhez hogy a brotli a PATH-ben legyen
|
||||
|
||||
## Windows
|
||||
- Töltsd le a `brotli-x64-windows-static.zip`-et a [google/brotli github repoból](https://github.com/google/brotli/releases/latest)
|
||||
- Csomagold ki valahol (pl. C:\Users\\<username>\dev\brotli)
|
||||
- Add hozzá a mappát ahova kicsomagoltad (C:\Users\\<username>\dev\brotli) a PATH-hez
|
||||
- Ne felejtsd el újraindítani az IDE-det illetve parancssorodat utánna hogy frissüljön a PATH
|
||||
|
||||
## Linux/MacOS
|
||||
Telepítsd fel a brotli packaget a distro-d package managerével
|
||||
A projekt jelenleg a 3.41.2-es Flutter SDK-t használja.
|
||||
|
||||
# Keystore
|
||||
|
||||
[Secrets dokumentáció](secrets/README.md)
|
||||
|
||||
# Flutter l10n
|
||||
# Fileok generálása
|
||||
|
||||
Flutter l10n fileok generálása
|
||||
Flutter l10n és egyéb fileok generálása
|
||||
|
||||
```shell
|
||||
Flutter gen-l10n --template-arb-file app_hu.arb
|
||||
$ cd firka # vagy firka_wear
|
||||
$ dart run scripts/codegen.dart
|
||||
```
|
||||
|
||||
# Android debug build
|
||||
@@ -42,20 +26,10 @@ $ Flutter build apk --debug --target-platform android-arm,android-arm64,android-
|
||||
|
||||
# Android release build
|
||||
|
||||
A release buildhez közelező egy keystore használata, illetve a saját Flutter engineünk használata.
|
||||
A release buildhez közelező egy keystore használata.
|
||||
|
||||
## Custom Flutter engine setupolása
|
||||
## Release appbundle buildelése (firka és firka_wear)
|
||||
|
||||
```shell
|
||||
$ git clone https://git.firka.app/firka/flutter
|
||||
$ cd flutter
|
||||
$ . dev/tools/envsetup.sh
|
||||
$ gclient sync -D
|
||||
$ ./dev/tools/build_release.sh
|
||||
```
|
||||
|
||||
## Release apk buildelése
|
||||
|
||||
```shell
|
||||
$ ./tools/linux/build_apk.sh main
|
||||
$ ./build.sh
|
||||
```
|
||||
@@ -1,41 +1,24 @@
|
||||
# Installing flutter
|
||||
# Installing Flutter
|
||||
|
||||
To build firka you will have to use our custom Flutter fork,
|
||||
and to make a release build you will have to use our custom
|
||||
flutter engine.
|
||||
The documentation for installing flutter can be found [here](https://docs.flutter.dev/get-started/install).
|
||||
|
||||
Instead of downloading the regular flutter zip, clone it from ([https://git.firka.app/firka/flutter/](https://git.firka.app/firka/flutter/)).
|
||||
|
||||
# Brotli
|
||||
|
||||
Firka uses brotli to compress libflutter during the build process to make the app smaller,
|
||||
so building Firka requires you to have brotli in your path
|
||||
|
||||
## Windows
|
||||
- Download `brotli-x64-windows-static.zip` from [google/brotli](https://github.com/google/brotli/releases/latest)
|
||||
- Extract it to somewhere like C:\Users\\<username>\dev\brotli
|
||||
- Add the directory (ex. C:\Users\\<username>\dev\brotli) to your PATH
|
||||
- Don't forget to restart your IDE or terminal sessions for the PATH variable to update
|
||||
|
||||
## Linux/MacOS
|
||||
Install it using your distro's package manager
|
||||
Flutter installation documentation can be found [here](https://docs.flutter.dev/get-started/install).
|
||||
The project currently uses Flutter SDK 3.41.2.
|
||||
|
||||
# Keystore
|
||||
|
||||
[Secrets docs](secrets/README_en.md)
|
||||
[Secrets documentation](secrets/README.md)
|
||||
|
||||
# Flutter l10n
|
||||
# Generating files
|
||||
|
||||
Generating flutter l10n files
|
||||
Generating Flutter l10n and other files
|
||||
|
||||
```shell
|
||||
flutter gen-l10n --template-arb-file app_hu.arb
|
||||
$ cd firka # or firka_wear
|
||||
$ dart run scripts/codegen.dart
|
||||
```
|
||||
|
||||
# Android debug build
|
||||
|
||||
The dev build doesn't require using a custom keystore
|
||||
The dev build does not require using a keystore
|
||||
```shell
|
||||
$ cd firka
|
||||
$ flutter build apk --debug --target-platform android-arm,android-arm64,android-x64
|
||||
@@ -43,20 +26,10 @@ $ flutter build apk --debug --target-platform android-arm,android-arm64,android-
|
||||
|
||||
# Android release build
|
||||
|
||||
The release build requires using a custom keystore and our custom flutter fork
|
||||
The release build requires using a keystore.
|
||||
|
||||
## Setting up our flutter engine fork
|
||||
## Building the release appbundle (firka and firka_wear)
|
||||
|
||||
```shell
|
||||
$ git clone https://git.firka.app/firka/flutter
|
||||
$ cd flutter
|
||||
$ . dev/tools/envsetup.sh
|
||||
$ gclient sync -D
|
||||
$ ./dev/tools/build_release.sh
|
||||
$ ./build.sh
|
||||
```
|
||||
|
||||
## Building the release apk
|
||||
|
||||
```shell
|
||||
$ ./tools/linux/build_apk.sh main
|
||||
```
|
||||
195
Jenkinsfile
vendored
@@ -1,167 +1,66 @@
|
||||
pipeline {
|
||||
agent {
|
||||
label 'ubuntu'
|
||||
}
|
||||
agent any
|
||||
environment {
|
||||
PATH = "/home/jenkins/development/flutter/bin:${env.PATH}"
|
||||
FLUTTER_ROOT = "/opt/flutter"
|
||||
FLUTTER = "/opt/flutter/bin/flutter"
|
||||
DART = "/opt/flutter/bin/dart"
|
||||
ANDROID_SDK_ROOT = "/opt/android-sdk"
|
||||
ANDROID_HOME = "/opt/android-sdk"
|
||||
}
|
||||
stages {
|
||||
stage('Pre-build Cleanup') {
|
||||
stage('Clone Submodules') {
|
||||
steps {
|
||||
script {
|
||||
sh '''#!/bin/sh
|
||||
set -x
|
||||
fusermount -u secrets || true
|
||||
'''
|
||||
}
|
||||
sh 'git submodule update --init --recursive'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Decrypt main keys') {
|
||||
when {
|
||||
branch 'main'
|
||||
}
|
||||
stage('Dependencies') {
|
||||
steps {
|
||||
script {
|
||||
def userInput = input(
|
||||
id: 'signaturePassword',
|
||||
message: 'Please enter the signing key password:',
|
||||
parameters: [
|
||||
password(
|
||||
defaultValue: '',
|
||||
description: 'Enter the signing key password',
|
||||
name: 'password'
|
||||
)
|
||||
]
|
||||
)
|
||||
env.PASSWORD = userInput.toString()
|
||||
}
|
||||
sh '''#!/bin/sh
|
||||
echo \$PASSWORD | gocryptfs $HOME/android_secrets secrets/ -nonempty
|
||||
sh '''
|
||||
cd firka
|
||||
$FLUTTER pub get
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Clone submodules') {
|
||||
stage('Setup') {
|
||||
steps {
|
||||
sh '''
|
||||
cp -r /opt/secrets ./
|
||||
'''
|
||||
}
|
||||
}
|
||||
stage('Codegen') {
|
||||
steps {
|
||||
script {
|
||||
sh 'git submodule update --init --recursive'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build firka') {
|
||||
steps {
|
||||
sh 'bash -c "./tools/linux/build_apk.sh ' + env.BRANCH_NAME + '"'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Rename Release APKs') {
|
||||
when {
|
||||
branch 'main'
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
sh '''#!/bin/sh
|
||||
set -e
|
||||
|
||||
APK_DIR="firka/build/app/outputs/flutter-apk"
|
||||
|
||||
# Find all release APKs and rename them
|
||||
for apk_file in $APK_DIR/app-*-release.apk; do
|
||||
if [ -f "$apk_file" ]; then
|
||||
# Extract ABI from filename (e.g., app-arm64-v8a-release.apk -> arm64-v8a)
|
||||
basename_file=$(basename "$apk_file")
|
||||
abi=$(echo "$basename_file" | sed 's/app-//; s/-release.apk//')
|
||||
|
||||
# Create new filename
|
||||
new_name="app.firka.naplo_${abi}.apk"
|
||||
new_path="$APK_DIR/$new_name"
|
||||
|
||||
echo "Renaming $apk_file to $new_path"
|
||||
mv "$apk_file" "$new_path"
|
||||
fi
|
||||
done
|
||||
|
||||
ls -la $APK_DIR/app.firka.naplo_*.apk || echo "APK files not found"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Calculate Version Code') {
|
||||
steps {
|
||||
script {
|
||||
sh '''#!/bin/sh
|
||||
set -e
|
||||
sh '''
|
||||
cd firka
|
||||
|
||||
# Calculate version code based on git commits (same logic as build script)
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
BASE_BUILD_NUMBER=$((1300 + COMMIT_COUNT))
|
||||
|
||||
if [ "$BRANCH_NAME" = "main" ]; then
|
||||
# For main branch, highest version code is BASE + 3000 (x64 build)
|
||||
VERSION_CODE=$((BASE_BUILD_NUMBER + 3000))
|
||||
else
|
||||
# For debug builds, version code is BASE + 0
|
||||
VERSION_CODE=$BASE_BUILD_NUMBER
|
||||
fi
|
||||
|
||||
echo "Calculated version code: $VERSION_CODE"
|
||||
echo "$VERSION_CODE" > ../version_code.txt
|
||||
'''
|
||||
|
||||
env.VERSION_CODE = readFile('version_code.txt').trim()
|
||||
echo "Setting VERSION_CODE environment variable to: ${env.VERSION_CODE}"
|
||||
}
|
||||
PATH="/opt/flutter/bin:$PATH" $DART run scripts/codegen.dart
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish debug artifacts') {
|
||||
when {
|
||||
not {
|
||||
branch 'main'
|
||||
}
|
||||
}
|
||||
stage('Build') {
|
||||
steps {
|
||||
sh '''
|
||||
cd firka
|
||||
echo "--- Checking secrets path ---"
|
||||
ls android/app/
|
||||
ls android/app/../../../ || echo "no parent"
|
||||
ls android/app/../../../secrets/ || echo "secrets not found at expected path"
|
||||
$FLUTTER config --android-sdk /opt/android-sdk
|
||||
$FLUTTER build apk --release
|
||||
'''
|
||||
}
|
||||
}
|
||||
stage('Archive') {
|
||||
steps {
|
||||
archiveArtifacts artifacts: 'firka/build/app/outputs/flutter-apk/app-debug.apk', fingerprint: true
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish release AABs artifacts') {
|
||||
when {
|
||||
branch 'main'
|
||||
}
|
||||
steps {
|
||||
archiveArtifacts artifacts: 'firka/build/app/outputs/bundle/release/*.aab', fingerprint: true
|
||||
sh 'rm firka/build/app/outputs/bundle/release/*.aab'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish release APKs artifacts') {
|
||||
when {
|
||||
branch 'main'
|
||||
}
|
||||
steps {
|
||||
archiveArtifacts artifacts: 'firka/build/app/outputs/flutter-apk/app.firka.naplo_*.apk', fingerprint: true
|
||||
sh 'rm firka/build/app/outputs/flutter-apk/app.firka.naplo_*.apk'
|
||||
}
|
||||
}
|
||||
|
||||
stage('Post Cleanup') {
|
||||
steps {
|
||||
script {
|
||||
sh '''
|
||||
rm firka/build/app/outputs/bundle/release/*.aab || true
|
||||
rm firka/build/app/outputs/flutter-apk/app.firka.naplo_*.apk || true
|
||||
rm firka/build/app/outputs/flutter-apk/app-debug.apk || true
|
||||
rm version_code.txt || true
|
||||
git checkout -- firka/pubspec.yaml || true
|
||||
git checkout -- firka/lib/helpers/firka_bundle.dart || true
|
||||
'''
|
||||
}
|
||||
archiveArtifacts(
|
||||
artifacts: 'firka/build/app/outputs/flutter-apk/app-debug.apk,firka/build/app/outputs/flutter-apk/app-release.apk',
|
||||
fingerprint: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
deleteDir()
|
||||
}
|
||||
}
|
||||
}
|
||||
52
build.ps1
Normal file
@@ -0,0 +1,52 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ROOT = $PSScriptRoot
|
||||
$SHA = (git -C $ROOT rev-parse --short HEAD)
|
||||
$COMMIT_COUNT = [int](git -C $ROOT rev-list --count HEAD)
|
||||
|
||||
function Build-App {
|
||||
param([string]$App)
|
||||
|
||||
$pubspec = Join-Path $ROOT $App "pubspec.yaml"
|
||||
if (-not (Test-Path $pubspec)) {
|
||||
Write-Error "Not found: $pubspec"
|
||||
}
|
||||
|
||||
$versionLine = Get-Content $pubspec | Select-String -Pattern '^\s*version:\s*' | Select-Object -First 1
|
||||
if (-not $versionLine) {
|
||||
Write-Error "No version line in $pubspec"
|
||||
}
|
||||
$line = $versionLine.Line
|
||||
if ($line -match '^\s*version:\s*([^+\s]+)') {
|
||||
$baseVersion = $Matches[1].Trim()
|
||||
} else {
|
||||
Write-Error "Could not parse version from: $line"
|
||||
}
|
||||
|
||||
$buildName = "${baseVersion}-${SHA}"
|
||||
$versionCode = 2000 + $COMMIT_COUNT
|
||||
if ($App -eq "firka_wear") {
|
||||
$versionCode += 1
|
||||
}
|
||||
|
||||
Write-Host "Building $App : version $buildName (version code: $versionCode)"
|
||||
Push-Location (Join-Path $ROOT $App)
|
||||
try {
|
||||
flutter pub get
|
||||
dart run scripts/codegen.dart
|
||||
flutter build appbundle --build-name="$buildName" --build-number="$versionCode" --verbose
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
$target = if ($args.Count -gt 0) { $args[0] } else { "all" }
|
||||
|
||||
switch ($target) {
|
||||
"firka" { Build-App firka }
|
||||
"firka_wear" { Build-App firka_wear }
|
||||
"all" { Build-App firka; Build-App firka_wear }
|
||||
default {
|
||||
Write-Error "Usage: $MyInvocation.MyCommand.Name [firka|firka_wear|all]"
|
||||
}
|
||||
}
|
||||
41
build.sh
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
SHA=$(git -C "$ROOT" rev-parse --short HEAD)
|
||||
COMMIT_COUNT=$(git -C "$ROOT" rev-list --count HEAD)
|
||||
|
||||
build_app() {
|
||||
local APP="$1"
|
||||
local PUBSPEC="$ROOT/$APP/pubspec.yaml"
|
||||
if [[ ! -f "$PUBSPEC" ]]; then
|
||||
echo "Not found: $PUBSPEC" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local VERSION_LINE BASE_VERSION BUILD_NAME VERSION_CODE
|
||||
VERSION_LINE=$(grep -E '^\s*version:\s*' "$PUBSPEC" | head -1)
|
||||
BASE_VERSION=$(echo "$VERSION_LINE" | sed -E 's/^[[:space:]]*version:[[:space:]]*([^+]+).*/\1/' | tr -d ' ')
|
||||
BUILD_NAME="${BASE_VERSION}-${SHA}"
|
||||
|
||||
VERSION_CODE=$((2000 + COMMIT_COUNT))
|
||||
[[ "$APP" == "firka_wear" ]] && VERSION_CODE=$((VERSION_CODE + 1))
|
||||
|
||||
echo "Building $APP: version $BUILD_NAME (version code: $VERSION_CODE)"
|
||||
cd "$ROOT/$APP"
|
||||
|
||||
flutter pub get
|
||||
dart run scripts/codegen.dart
|
||||
|
||||
flutter build appbundle --build-name="$BUILD_NAME" --build-number="$VERSION_CODE" --verbose
|
||||
}
|
||||
|
||||
case "${1:-all}" in
|
||||
firka) build_app firka ;;
|
||||
firka_wear) build_app firka_wear ;;
|
||||
all) build_app firka && build_app firka_wear ;;
|
||||
*)
|
||||
echo "Usage: $0 [firka|firka_wear|all]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
5
firka/.gitignore
vendored
@@ -49,4 +49,7 @@ app.*.map.json
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
coverage
|
||||
coverage
|
||||
|
||||
# Generated files
|
||||
*.g.dart
|
||||
@@ -1,36 +1,17 @@
|
||||
import org.apache.commons.io.FileUtils
|
||||
import java.io.FileInputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.Properties
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
import java.util.zip.ZipOutputStream.DEFLATED
|
||||
import java.util.zip.ZipOutputStream.STORED
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0"
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
fun loadProperties(file: File): Properties {
|
||||
val properties = Properties()
|
||||
FileInputStream(file).use { inputStream ->
|
||||
properties.load(inputStream)
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "app.firka.naplo"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = "27.0.12077973"
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
@@ -41,16 +22,12 @@ android {
|
||||
compose = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "app.firka.naplo"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
@@ -59,7 +36,7 @@ android {
|
||||
val propsFile = File(secretsDir, "keystore.properties")
|
||||
|
||||
if (propsFile.exists()) {
|
||||
val props = loadProperties(propsFile)
|
||||
val props = Properties().apply { FileInputStream(propsFile).use { load(it) } }
|
||||
val store = File(secretsDir, props["storeFile"].toString())
|
||||
|
||||
signingConfigs {
|
||||
@@ -68,6 +45,10 @@ android {
|
||||
storePassword = props["storePassword"] as String
|
||||
keyPassword = props["keyPassword"] as String
|
||||
keyAlias = props["keyAlias"] as String
|
||||
// Use APK Signature Scheme v3 (and v4 for streaming verification). See:
|
||||
// https://source.android.com/docs/security/features/apksigning/v3
|
||||
enableV3Signing = true
|
||||
enableV4Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,809 +69,33 @@ android {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.wear:wear-ongoing:1.0.0")
|
||||
implementation("androidx.glance:glance-appwidget:1.1.1")
|
||||
implementation("com.google.android.gms:play-services-wearable:18.1.0")
|
||||
}
|
||||
|
||||
// Ensure .env exists before Flutter bundles assets (copy from .env.example if missing)
|
||||
val envFile = file("${project.projectDir}/../../.env")
|
||||
val envExampleFile = file("${project.projectDir}/../../.env.example")
|
||||
tasks.register("ensureEnv") {
|
||||
doLast {
|
||||
if (!envFile.exists() && envExampleFile.exists()) {
|
||||
envExampleFile.copyTo(envFile, overwrite = false)
|
||||
println("Created .env from .env.example for asset bundling.")
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.matching { it.name.startsWith("compileFlutterBuild") }.configureEach {
|
||||
dependsOn("ensureEnv")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
tasks.register("transformAndResignDebugApk") {
|
||||
group = "build"
|
||||
description = "Transform and resign APK with debug key"
|
||||
|
||||
dependsOn("assembleDebug")
|
||||
|
||||
doLast {
|
||||
if (System.getenv("TRANSFORM_APK") != null
|
||||
&& System.getenv("TRANSFORM_APK") == "true") {
|
||||
transformApks(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("transformAndResignReleaseApk") {
|
||||
group = "build"
|
||||
description = "Transform and resign APK with release key"
|
||||
|
||||
dependsOn("assembleRelease")
|
||||
|
||||
doLast {
|
||||
checkReleaseKey()
|
||||
if (System.getenv("TRANSFORM_APK") != null
|
||||
&& System.getenv("TRANSFORM_APK") == "true") {
|
||||
transformApks(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("transformAndResignReleaseBundle") {
|
||||
group = "build"
|
||||
description = "Transform and resign bundle with release key"
|
||||
|
||||
dependsOn("bundleRelease")
|
||||
|
||||
doLast {
|
||||
if (System.getenv("TRANSFORM_AAB") != null
|
||||
&& System.getenv("TRANSFORM_AAB") == "true") {
|
||||
transformAppBundle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.findByName("assembleDebug")?.finalizedBy("transformAndResignDebugApk")
|
||||
tasks.findByName("assembleRelease")?.finalizedBy("transformAndResignReleaseApk")
|
||||
tasks.findByName("bundleRelease")?.finalizedBy("transformAndResignReleaseBundle")
|
||||
}
|
||||
|
||||
fun checkReleaseKey() {
|
||||
val secretsDir = File(projectDir.absolutePath, "../../../secrets/")
|
||||
val propsFile = File(secretsDir, "keystore.properties")
|
||||
|
||||
if (propsFile.exists()) {
|
||||
val props = loadProperties(propsFile)
|
||||
val store = File(secretsDir, props["storeFile"].toString())
|
||||
|
||||
println(
|
||||
"Signing with:\n" +
|
||||
"\t- store: ${store.name}\n" +
|
||||
"\t- key: ${props["keyAlias"]}"
|
||||
)
|
||||
} else {
|
||||
throw Exception("Release keystore not found!")
|
||||
}
|
||||
}
|
||||
|
||||
fun transformApks(debug: Boolean, i : Int = 0) {
|
||||
try {
|
||||
_transformApks(debug)
|
||||
} catch (e: Exception) {
|
||||
if (i < 5) {
|
||||
e.printStackTrace()
|
||||
|
||||
println("Retrying: ${i + 1}")
|
||||
transformApks(debug, i + 1)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun _transformApks(debug: Boolean) {
|
||||
println("Starting APK transformation process...")
|
||||
|
||||
val buildDir = project.buildDir
|
||||
val apkDir = File(buildDir, "outputs/flutter-apk")
|
||||
val apks = getApks(debug)
|
||||
var c = 0
|
||||
apks
|
||||
.forEach { c++; transformAndSignApk(apkDir, it.nameWithoutExtension, debug) }
|
||||
|
||||
println("Transformed: $c apks")
|
||||
}
|
||||
|
||||
fun transformAndSignApk(apkDir: File, name: String, debug: Boolean) {
|
||||
val originalApk = File(apkDir, "$name.apk")
|
||||
val transformedApk = File(apkDir, "$name-transformed.apk")
|
||||
val finalApk = File(apkDir, "$name-resigned.apk")
|
||||
val finalIdsig = File(apkDir, "$name-resigned.apk.idsig")
|
||||
|
||||
if (!originalApk.exists()) {
|
||||
throw GradleException("Original APK not found at: ${originalApk.absolutePath}")
|
||||
}
|
||||
|
||||
if (transformedApk.exists()) transformedApk.delete()
|
||||
if (finalApk.exists()) finalApk.delete()
|
||||
|
||||
println("Original APK: ${originalApk.absolutePath}")
|
||||
|
||||
try {
|
||||
println("Transforming APK...")
|
||||
transformApk(originalApk, transformedApk, if (debug) { "6" } else {"Z"})
|
||||
|
||||
if (debug) {
|
||||
println("Signing with debug key...")
|
||||
signWithDebugKey(transformedApk, finalApk)
|
||||
} else {
|
||||
println("Signing with release key...")
|
||||
signWithReleaseKey(transformedApk, finalApk)
|
||||
}
|
||||
|
||||
if (finalApk.exists()) {
|
||||
originalApk.delete()
|
||||
finalIdsig.delete()
|
||||
finalApk.renameTo(originalApk)
|
||||
println("APK successfully transformed")
|
||||
println("Final APK: ${originalApk.absolutePath}")
|
||||
}
|
||||
|
||||
transformedApk.delete()
|
||||
} catch (e: Exception) {
|
||||
throw GradleException("Failed to transform and resign APK: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun transformApk(input: File, output: File, compressionLevel: String = "Z") {
|
||||
val tempDir = File(project.buildDir, "tmp/apk-transform")
|
||||
val cacheDir = File(project.buildDir, "cache")
|
||||
val optipngCacheDir = File(cacheDir, "optipng")
|
||||
val assetCompressionDir = File(cacheDir, "assets")
|
||||
tempDir.deleteRecursively()
|
||||
tempDir.mkdirs()
|
||||
if (!optipngCacheDir.exists()) optipngCacheDir.mkdirs()
|
||||
if (!assetCompressionDir.exists()) assetCompressionDir.mkdirs()
|
||||
|
||||
val brotli = findToolInPath("brotli")
|
||||
?: throw Exception("Brotli not found in path")
|
||||
val optipng = findToolInPath("optipng")
|
||||
|
||||
if (optipng == null || optipng.isEmpty()) {
|
||||
println("Optipng was not found in PATH, optimizing images will be skipped.")
|
||||
}
|
||||
|
||||
copy {
|
||||
from(zipTree(input))
|
||||
into(tempDir)
|
||||
}
|
||||
|
||||
val metaInf = File(tempDir, "META-INF")
|
||||
val metaInfFiles = metaInf.listFiles()
|
||||
for (file in metaInfFiles!!) {
|
||||
if (file.name.endsWith("MF") || file.name.endsWith("SF")
|
||||
|| file.name.endsWith("RSA")) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
val arches = File(tempDir, "lib").listFiles()
|
||||
val compressedLibs = mutableMapOf<String, String>()
|
||||
for (arch in arches!!) {
|
||||
val libFlutter = File(arch, "libflutter.so")
|
||||
|
||||
if (!libFlutter.exists()) continue
|
||||
|
||||
val compressedFlutter = File(arch, "libflutter-br.so")
|
||||
|
||||
compressedLibs["libflutter.so"] = libFlutter.sha256()
|
||||
|
||||
println("Compressing ${arch.name}/libflutter.so with brotli")
|
||||
exec {
|
||||
commandLine(
|
||||
brotli,
|
||||
"-$compressionLevel",
|
||||
libFlutter.absolutePath,
|
||||
"-o", compressedFlutter.absolutePath
|
||||
)
|
||||
}
|
||||
libFlutter.delete()
|
||||
|
||||
val json = groovy.json.JsonBuilder(compressedLibs)
|
||||
File(arch, "index.so").writeText(json.toString())
|
||||
}
|
||||
|
||||
val topDirL = tempDir.absolutePath.length + 1
|
||||
val zos = ZipOutputStream(output.outputStream())
|
||||
|
||||
val coreCount = Runtime.getRuntime().availableProcessors()
|
||||
val flutterResources = tempDir.walkTopDown().filter{f -> f.absolutePath.contains("flutter_assets")}
|
||||
val pngFiles = tempDir.walkTopDown().filter{f -> f.name.endsWith(".png")}
|
||||
|
||||
val assetIndex = mutableMapOf<String, String>()
|
||||
val indexReadWriteLock = ReentrantReadWriteLock()
|
||||
|
||||
if (compressionLevel == "Z") {
|
||||
if (optipng != null) {
|
||||
val executor = Executors.newFixedThreadPool(coreCount)
|
||||
val futures = mutableListOf<Future<*>>()
|
||||
|
||||
pngFiles.forEach { pngFile ->
|
||||
val cacheFile = File(optipngCacheDir, pngFile.sha256())
|
||||
|
||||
if (cacheFile.exists()) {
|
||||
cacheFile.copyTo(pngFile, true)
|
||||
} else {
|
||||
val future = executor.submit {
|
||||
exec {
|
||||
commandLine(
|
||||
optipng,
|
||||
"-zm", "9",
|
||||
"-zw", "32k",
|
||||
"-o9",
|
||||
pngFile.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
pngFile.copyTo(cacheFile, true)
|
||||
}
|
||||
|
||||
futures.add(future)
|
||||
}
|
||||
}
|
||||
|
||||
futures.forEach { it.get() }
|
||||
executor.shutdown()
|
||||
}
|
||||
|
||||
val executor = Executors.newFixedThreadPool(coreCount)
|
||||
val futures = mutableListOf<Future<*>>()
|
||||
|
||||
val blacklist = listOf(
|
||||
// "AssetManifest.bin",
|
||||
"AssetManifest.json",
|
||||
"FontManifest.json",
|
||||
"isolate_snapshot_data",
|
||||
"kernel_blob.bin",
|
||||
"NativeAssetsManifest.json",
|
||||
"NOTICES.Z",
|
||||
"vm_snapshot_data",
|
||||
"fonts",
|
||||
"shaders"
|
||||
)
|
||||
|
||||
flutterResources.forEach { f ->
|
||||
val relName = f.absolutePath.substring(topDirL).replace("\\", "/")
|
||||
if (f.isDirectory) return@forEach
|
||||
|
||||
val cacheFileRaw = File(assetCompressionDir, f.sha256()+".r")
|
||||
val cacheFileGz = File(assetCompressionDir, f.sha256()+".gz")
|
||||
val cacheFileBr = File(assetCompressionDir, f.sha256()+".br")
|
||||
|
||||
if (cacheFileRaw.exists() || cacheFileGz.exists() || cacheFileBr.exists()) {
|
||||
if (cacheFileRaw.exists()) {
|
||||
cacheFileRaw.copyTo(f, true)
|
||||
|
||||
indexReadWriteLock.writeLock().lock()
|
||||
assetIndex[relName] = "r"
|
||||
indexReadWriteLock.writeLock().unlock()
|
||||
} else if (cacheFileGz.exists()) {
|
||||
cacheFileGz.copyTo(f, true)
|
||||
|
||||
indexReadWriteLock.writeLock().lock()
|
||||
assetIndex[relName] = "g"
|
||||
indexReadWriteLock.writeLock().unlock()
|
||||
} else {
|
||||
cacheFileBr.copyTo(f, true)
|
||||
|
||||
indexReadWriteLock.writeLock().lock()
|
||||
assetIndex[relName] = "b"
|
||||
indexReadWriteLock.writeLock().unlock()
|
||||
}
|
||||
} else {
|
||||
val future = executor.submit {
|
||||
val brTmp = File(f.absolutePath + ".br.tmp")
|
||||
val gzTmp = File(f.absolutePath + ".gz.tmp")
|
||||
|
||||
var blacklisted = false
|
||||
for (f in blacklist) {
|
||||
if (relName.contains(f)) {
|
||||
blacklisted = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!blacklisted) {
|
||||
println("$relName: Testing with brotli")
|
||||
exec {
|
||||
commandLine(
|
||||
brotli,
|
||||
"-$compressionLevel",
|
||||
f.absolutePath,
|
||||
"-o", brTmp.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
println("$relName: Testing with gzip")
|
||||
ant.invokeMethod(
|
||||
"gzip", mapOf(
|
||||
"src" to f.absolutePath,
|
||||
"destfile" to gzTmp.absolutePath,
|
||||
)
|
||||
)
|
||||
|
||||
println("$brTmp: ${brTmp.length()}")
|
||||
println("$gzTmp: ${gzTmp.length()}")
|
||||
if (f.length() < gzTmp.length() && f.length() < brTmp.length()) {
|
||||
println("$relName: Raw file wins")
|
||||
|
||||
f.copyTo(cacheFileRaw, true)
|
||||
|
||||
indexReadWriteLock.writeLock().lock()
|
||||
assetIndex[relName] = "r"
|
||||
indexReadWriteLock.writeLock().unlock()
|
||||
} else {
|
||||
if (brTmp.length() < gzTmp.length()) {
|
||||
println("$relName: Brotli wins")
|
||||
|
||||
f.delete()
|
||||
brTmp.copyTo(f, true)
|
||||
brTmp.copyTo(cacheFileBr, true)
|
||||
|
||||
indexReadWriteLock.writeLock().lock()
|
||||
assetIndex[relName] = "b"
|
||||
indexReadWriteLock.writeLock().unlock()
|
||||
} else {
|
||||
println("$relName: Gzip wins")
|
||||
|
||||
f.delete()
|
||||
gzTmp.copyTo(f, true)
|
||||
gzTmp.copyTo(cacheFileGz, true)
|
||||
|
||||
indexReadWriteLock.writeLock().lock()
|
||||
assetIndex[relName] = "g"
|
||||
indexReadWriteLock.writeLock().unlock()
|
||||
}
|
||||
}
|
||||
|
||||
brTmp.delete()
|
||||
gzTmp.delete()
|
||||
}
|
||||
}
|
||||
|
||||
futures.add(future)
|
||||
}
|
||||
}
|
||||
|
||||
futures.forEach { it.get() }
|
||||
executor.shutdown()
|
||||
}
|
||||
|
||||
tempDir.walkTopDown().forEach { f ->
|
||||
if (f.absolutePath == tempDir.absolutePath) return@forEach
|
||||
|
||||
var relName = f.absolutePath.substring(topDirL).replace("\\", "/")
|
||||
if (f.isDirectory && !relName.endsWith("/")) relName += "/"
|
||||
|
||||
if (compressionLevel == "Z") {
|
||||
if (relName == "assets/flutter_assets/assets/firka.i") return@forEach
|
||||
}
|
||||
|
||||
println(relName)
|
||||
|
||||
val compress = !relName.endsWith(".so") && !relName.endsWith(".arsc")
|
||||
zos.setMethod(if (compress) { DEFLATED } else { STORED })
|
||||
val entry = ZipEntry(relName)
|
||||
if (!compress) {
|
||||
entry.size = f.length()
|
||||
entry.crc = FileUtils.checksumCRC32(f)
|
||||
}
|
||||
zos.putNextEntry(entry)
|
||||
if (f.isFile) {
|
||||
zos.write(f.readBytes())
|
||||
}
|
||||
zos.closeEntry()
|
||||
}
|
||||
if (compressionLevel == "Z") {
|
||||
zos.setMethod(DEFLATED)
|
||||
zos.putNextEntry(ZipEntry("assets/flutter_assets/assets/firka.i"))
|
||||
|
||||
val indexUncompressed = File(tempDir, "index.json")
|
||||
indexReadWriteLock.readLock().lock()
|
||||
val json = groovy.json.JsonBuilder(assetIndex)
|
||||
indexReadWriteLock.readLock().unlock()
|
||||
indexUncompressed.writeText(json.toString())
|
||||
|
||||
val indexCompressed = File(tempDir, "index.json.br")
|
||||
|
||||
exec {
|
||||
commandLine(
|
||||
brotli,
|
||||
"-$compressionLevel",
|
||||
indexUncompressed.absolutePath,
|
||||
"-o", indexCompressed.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
zos.write(indexCompressed.readBytes())
|
||||
indexUncompressed.delete()
|
||||
indexCompressed.delete()
|
||||
|
||||
zos.closeEntry()
|
||||
}
|
||||
zos.close()
|
||||
|
||||
tempDir.deleteRecursively()
|
||||
println("APK transformed successfully")
|
||||
}
|
||||
|
||||
fun transformAppBundle() {
|
||||
val buildDir = project.buildDir
|
||||
val bundle = File(buildDir, "outputs/bundle/release/app-release.aab")
|
||||
val bundleTmp = File(buildDir, "outputs/bundle/release/tmp.zip")
|
||||
|
||||
val apks = getApks(false)
|
||||
val apkCount = apks.count { it.name.startsWith("app-") && it.name.endsWith("-release.apk") }
|
||||
|
||||
if (!bundle.exists()) {
|
||||
throw Exception("Bundle not found at: $bundle")
|
||||
}
|
||||
|
||||
if (apkCount < 3) {
|
||||
throw Exception("Excepected 3 apks per abi but only found $apkCount")
|
||||
}
|
||||
|
||||
val aabTempDir = File(project.buildDir, "tmp/aab-transform")
|
||||
aabTempDir.deleteRecursively()
|
||||
aabTempDir.mkdirs()
|
||||
|
||||
val apksUnzipped = File(project.buildDir, "tmp/apks-unzipped")
|
||||
apksUnzipped.deleteRecursively()
|
||||
|
||||
val arm32TempDir = File(apksUnzipped, "armeabi-v7a")
|
||||
arm32TempDir.mkdirs()
|
||||
val arm64TempDir = File(apksUnzipped, "arm64-v8a")
|
||||
arm64TempDir.mkdirs()
|
||||
val x86TempDir = File(apksUnzipped, "x86_64")
|
||||
x86TempDir.mkdirs()
|
||||
|
||||
copy {
|
||||
from(zipTree(bundle))
|
||||
into(aabTempDir)
|
||||
}
|
||||
copy {
|
||||
from(zipTree(apks.first { it.name.contains("armeabi-v7a") }))
|
||||
into(arm32TempDir)
|
||||
}
|
||||
copy {
|
||||
from(zipTree(apks.first { it.name.contains("arm64-v8a") }))
|
||||
into(arm64TempDir)
|
||||
}
|
||||
copy {
|
||||
from(zipTree(apks.first { it.name.contains("x86_64") }))
|
||||
into(x86TempDir)
|
||||
}
|
||||
|
||||
val libs = File(aabTempDir, "base/lib").listFiles()!!
|
||||
|
||||
for (dstLibs in libs) {
|
||||
println("Copying lib: ${dstLibs.name}")
|
||||
val srcDir = File(apksUnzipped, dstLibs.name)
|
||||
if (!srcDir.exists()) {
|
||||
continue
|
||||
}
|
||||
val srcLibs = File(srcDir, "lib/${dstLibs.name}/")
|
||||
|
||||
dstLibs.listFiles()!!.forEach { it.delete() }
|
||||
srcLibs.listFiles()!!.forEach { it.copyTo(File(dstLibs, it.name)) }
|
||||
}
|
||||
|
||||
val zos = ZipOutputStream(bundleTmp.outputStream())
|
||||
val bundleZip = ZipFile(bundle)
|
||||
val bundleEntries = bundleZip.entries()
|
||||
|
||||
val brotli = findToolInPath("brotli")
|
||||
?: throw Exception("Brotli not found in path")
|
||||
val optipng = findToolInPath("optipng")
|
||||
?: throw Exception("Optipng not found in path")
|
||||
|
||||
val indexReadWriteLock = ReentrantReadWriteLock()
|
||||
val assetIndex = mutableMapOf<String, String>()
|
||||
|
||||
while (bundleEntries.hasMoreElements()) {
|
||||
val entry = bundleEntries.nextElement()
|
||||
|
||||
/*
|
||||
if (entry.name == "base/assets/flutter_assets/assets/firka.i") {
|
||||
println("Patching: ${entry.name}")
|
||||
zos.putNextEntry(ZipEntry("assets/flutter_assets/assets/firka.i"))
|
||||
|
||||
val indexUncompressed = File(aabTempDir, "index.json")
|
||||
indexReadWriteLock.readLock().lock()
|
||||
val json = groovy.json.JsonBuilder(assetIndex)
|
||||
indexReadWriteLock.readLock().unlock()
|
||||
indexUncompressed.writeText(json.toString())
|
||||
|
||||
val indexCompressed = File(aabTempDir, "index.json.br")
|
||||
|
||||
exec {
|
||||
commandLine(
|
||||
brotli,
|
||||
"-Z",
|
||||
indexUncompressed.absolutePath,
|
||||
"-o", indexCompressed.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
zos.write(indexCompressed.readBytes())
|
||||
indexUncompressed.delete()
|
||||
indexCompressed.delete()
|
||||
|
||||
zos.closeEntry()
|
||||
continue
|
||||
}
|
||||
if (entry.name.startsWith("base/lib")) {
|
||||
println("Patching: ${entry.name}")
|
||||
zos.putNextEntry(ZipEntry(entry.name))
|
||||
|
||||
|
||||
|
||||
zos.closeEntry()
|
||||
continue
|
||||
}
|
||||
*/
|
||||
|
||||
println("Adding: ${entry.name}")
|
||||
|
||||
zos.putNextEntry(ZipEntry(entry.name))
|
||||
|
||||
if (!entry.isDirectory) {
|
||||
val data = bundleZip.getInputStream(entry).readAllBytes()
|
||||
zos.write(data)
|
||||
}
|
||||
zos.closeEntry()
|
||||
}
|
||||
bundleZip.close()
|
||||
zos.close()
|
||||
|
||||
bundle.delete()
|
||||
signBundle(bundleTmp, bundle)
|
||||
bundleTmp.delete()
|
||||
|
||||
aabTempDir.deleteRecursively()
|
||||
println("AAB transformed successfully")
|
||||
|
||||
}
|
||||
|
||||
fun File.sha256(): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val digest = md.digest(this.readBytes())
|
||||
return digest.fold("") { str, it -> str + "%02x".format(it) }
|
||||
}
|
||||
|
||||
fun getApks(debug: Boolean): List<File> {
|
||||
val buildDir = project.buildDir
|
||||
val apkDir = File(buildDir, "outputs/flutter-apk")
|
||||
val apks = apkDir.listFiles()!!
|
||||
val flavor = if (debug) { "debug" } else { "release" }
|
||||
|
||||
return apks
|
||||
.filter { apk -> apk.name.startsWith("app-") && apk.name.endsWith("-$flavor.apk") }
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun getDebugKeystorePath(): String {
|
||||
val userHome = System.getProperty("user.home")
|
||||
val debugKeystore = File(userHome, ".android/debug.keystore")
|
||||
|
||||
if (!debugKeystore.exists()) {
|
||||
throw GradleException("Debug keystore not found at: ${debugKeystore.absolutePath}")
|
||||
}
|
||||
|
||||
return debugKeystore.absolutePath
|
||||
}
|
||||
|
||||
fun getDefaultAndroidSdkPath(): String? {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
val userHome = System.getProperty("user.home")
|
||||
val zipAlign = File("/usr/bin/zipalign")
|
||||
|
||||
if (zipAlign.exists()) {
|
||||
return "/usr/bin"
|
||||
}
|
||||
|
||||
return when {
|
||||
os.contains("win") ->
|
||||
"$userHome\\AppData\\Local\\Android\\Sdk"
|
||||
os.contains("mac") ->
|
||||
"$userHome/Library/Android/sdk"
|
||||
os.contains("linux") ->
|
||||
"$userHome/Android/Sdk"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun findToolInPath(toolName: String): String? {
|
||||
val pathEnvironment = System.getenv("PATH")
|
||||
val pathDirs = pathEnvironment.split(File.pathSeparator)
|
||||
|
||||
val executableNames = when {
|
||||
System.getProperty("os.name").lowercase().contains("win") ->
|
||||
listOf("$toolName.exe", toolName)
|
||||
else ->
|
||||
listOf(toolName)
|
||||
}
|
||||
|
||||
for (pathDir in pathDirs) {
|
||||
for (execName in executableNames) {
|
||||
val possibleTool = File(pathDir, execName)
|
||||
if (possibleTool.exists() && possibleTool.canExecute()) {
|
||||
return possibleTool.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun findToolInSdkPath(toolName: String): String? {
|
||||
var androidHome : String? = System.getenv("ANDROID_HOME")
|
||||
?: System.getenv("ANDROID_SDK_ROOT")
|
||||
|
||||
if (androidHome == null) androidHome = getDefaultAndroidSdkPath()
|
||||
|
||||
if (androidHome != null) {
|
||||
val buildTools = File(androidHome, "build-tools")
|
||||
if (buildTools.exists()) {
|
||||
val latestVersion = buildTools.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.filter { it.name != "debian" }
|
||||
?.maxByOrNull { it.name }
|
||||
|
||||
if (latestVersion != null) {
|
||||
val toolExec = File(latestVersion, toolName)
|
||||
if (toolExec.exists()) {
|
||||
return toolExec.absolutePath
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val toolExec = File(androidHome, toolName)
|
||||
if (toolExec.exists()) {
|
||||
return toolExec.absolutePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!toolName.contains(".exe")) {
|
||||
val exeTool = findToolInSdkPath("$toolName.exe")
|
||||
if (exeTool != null) return exeTool
|
||||
}
|
||||
if (!toolName.contains(".sh")) {
|
||||
val shTool = findToolInSdkPath("$toolName.sh")
|
||||
if (shTool != null) return shTool
|
||||
}
|
||||
if (!toolName.contains(".bat")) {
|
||||
val batTool = findToolInSdkPath("$toolName.bat")
|
||||
if (batTool != null) return batTool
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun signWithDebugKey(input: File, output: File) {
|
||||
val debugKeystore = getDebugKeystorePath()
|
||||
val debugKeystorePassword = "android"
|
||||
val debugKeyAlias = "androiddebugkey"
|
||||
val debugKeyPassword = "android"
|
||||
|
||||
val zipAlign: String = findToolInSdkPath("zipalign")
|
||||
?: throw Exception("Could not find zipalign in ANDROID_SDK")
|
||||
val apksigner: String = findToolInSdkPath("apksigner")
|
||||
?: throw Exception("Could not find zipalign in ANDROID_SDK")
|
||||
|
||||
exec {
|
||||
commandLine(
|
||||
zipAlign,
|
||||
"-v", "4",
|
||||
input.absolutePath,
|
||||
output.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
exec {
|
||||
commandLine(
|
||||
apksigner, "sign",
|
||||
"--ks", debugKeystore,
|
||||
"--ks-pass", "pass:$debugKeystorePassword",
|
||||
"--ks-key-alias", debugKeyAlias,
|
||||
"--key-pass", "pass:$debugKeyPassword",
|
||||
output.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
println("APK signed and aligned successfully")
|
||||
}
|
||||
|
||||
fun signWithReleaseKey(input: File, output: File) {
|
||||
val secretsDir = File(projectDir.absolutePath, "../../../secrets/")
|
||||
val propsFile = File(secretsDir, "keystore.properties")
|
||||
|
||||
if (!propsFile.exists()) {
|
||||
throw Exception("Release keystore not found!")
|
||||
}
|
||||
|
||||
val props = loadProperties(propsFile)
|
||||
|
||||
val releaseKeystore = File(secretsDir, props["storeFile"].toString())
|
||||
val releaseKeystorePassword = props["storePassword"] as String
|
||||
val releaseKeyAlias = props["keyAlias"] as String
|
||||
val releaseKeyPassword = props["keyPassword"] as String
|
||||
|
||||
val zipAlign: String = findToolInSdkPath("zipalign")
|
||||
?: throw Exception("Could not find zipalign either in ANDROID_SDK")
|
||||
val apksigner: String = findToolInSdkPath("apksigner")
|
||||
?: throw Exception("Could not find zipalign either in ANDROID_SDK")
|
||||
|
||||
exec {
|
||||
commandLine(
|
||||
zipAlign,
|
||||
"-v", "4",
|
||||
input.absolutePath,
|
||||
output.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
exec {
|
||||
commandLine(
|
||||
apksigner, "sign",
|
||||
"--ks", releaseKeystore,
|
||||
"--ks-pass", "pass:$releaseKeystorePassword",
|
||||
"--ks-key-alias", releaseKeyAlias,
|
||||
"--key-pass", "pass:$releaseKeyPassword",
|
||||
output.absolutePath
|
||||
)
|
||||
}
|
||||
|
||||
println("APK signed and aligned successfully")
|
||||
}
|
||||
|
||||
fun signBundle(input: File, output: File) {
|
||||
val secretsDir = File(projectDir.absolutePath, "../../../secrets/")
|
||||
val propsFile = File(secretsDir, "keystore.properties")
|
||||
|
||||
if (!propsFile.exists()) {
|
||||
throw Exception("Release keystore not found!")
|
||||
}
|
||||
|
||||
val props = loadProperties(propsFile)
|
||||
|
||||
val releaseKeystore = File(secretsDir, props["storeFile"].toString())
|
||||
val releaseKeystorePassword = props["storePassword"] as String
|
||||
val releaseKeyAlias = props["keyAlias"] as String
|
||||
val releaseKeyPassword = props["keyPassword"] as String
|
||||
|
||||
// val zipAlign: String = findToolInSdkPath("zipalign")
|
||||
// ?: throw Exception("Could not find zipalign in ANDROID_SDK")
|
||||
val jarsigner: String = findToolInPath("jarsigner")
|
||||
?: throw Exception("Could not find jarsigner in PATH")
|
||||
|
||||
/*
|
||||
exec {
|
||||
commandLine(
|
||||
zipAlign,
|
||||
"-v", "4",
|
||||
input.absolutePath,
|
||||
output.absolutePath
|
||||
)
|
||||
}
|
||||
*/
|
||||
input.copyTo(output, true)
|
||||
|
||||
exec {
|
||||
// -keystore $KEYSTORE -storetype $STORETYPE -storepass $STOREPASS -digestalg SHA1 -sigalg SHA256withRSA application.zip $KEYALIAS
|
||||
commandLine(
|
||||
jarsigner,
|
||||
"-verbose",
|
||||
"-sigalg", "SHA256withRSA",
|
||||
"-digestalg", "SHA-256",
|
||||
"-keystore", releaseKeystore,
|
||||
"-storepass", releaseKeystorePassword,
|
||||
output.absolutePath,
|
||||
releaseKeyAlias
|
||||
)
|
||||
}
|
||||
|
||||
println("AAB signed and aligned successfully")
|
||||
}
|
||||
|
||||
1
firka/android/app/proguard-rules.pro
vendored
@@ -1,2 +1 @@
|
||||
-keep class org.brotli.** { *; }
|
||||
-keep class app.firka.naplo.glance.** { *; }
|
||||
@@ -5,11 +5,16 @@
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<application
|
||||
android:name=".AppMain"
|
||||
android:icon="@mipmap/launcher_icon">
|
||||
|
||||
<service
|
||||
android:name=".WearSyncForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -35,7 +40,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_ace"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_ace_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -49,7 +54,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_ace_f"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_ace_f_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -63,7 +68,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_bi"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_bi_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -77,7 +82,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_bi_f"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_bi_f_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -91,7 +96,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_cactus"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_cactus_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -105,7 +110,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_cc"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_cc_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -119,7 +124,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_enby"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_enby_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -133,7 +138,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_enby_f"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_enby_f_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -147,7 +152,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_fidesz"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_fidesz_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -161,7 +166,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_filc"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_filc_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -175,7 +180,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_filco"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_filco_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -189,7 +194,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_galaxy"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_galaxy_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -203,7 +208,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_gay"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_gay_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -217,7 +222,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_gay_f"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_gay_f_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -231,7 +236,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_half_firka_2"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_half_firka_2_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -245,7 +250,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_kreta"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_kreta_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -259,7 +264,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_lesb"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_lesb_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -273,7 +278,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_lesb_f"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_lesb_f_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -287,7 +292,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_lgbtq"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_lgbtq_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -301,7 +306,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_lgbtq_f"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_lgbtq_f_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -315,7 +320,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_lgbtqp"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_lgbtqp_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -329,7 +334,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_lgbtqp_f"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_lgbtqp_f_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -343,7 +348,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_lidl"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_lidl_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -357,7 +362,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_mkkp"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_mkkp_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -371,7 +376,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_nuke"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_nuke_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -385,7 +390,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_modern"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_modern_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -399,7 +404,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_o1g"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_o1g_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -413,7 +418,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_old"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_old_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -427,7 +432,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_paper"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_paper_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -441,7 +446,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_pear"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_pear_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -455,7 +460,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_pixel"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_pixel_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -469,7 +474,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_pixelized"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_pixelized_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -483,7 +488,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_pride"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_pride_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -497,7 +502,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_proto"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_proto_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -511,7 +516,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_refilc"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_refilc_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -525,7 +530,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_refulc"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_refulc_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -539,7 +544,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_repont"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_repont_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -553,7 +558,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_szivacs"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_szivacs_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -567,7 +572,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_tisza"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_tisza_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -581,7 +586,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_trans"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_trans_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -595,7 +600,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_trans_f"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_trans_f_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -609,7 +614,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_void_icon"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_void_icon_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -623,7 +628,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_xmas1"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_xmas1_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -637,7 +642,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_xmas2"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_xmas2_round" >
|
||||
|
||||
<intent-filter>
|
||||
@@ -651,7 +656,7 @@
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:enabled="false"
|
||||
android:icon="@mipmap/ic_xmas3"
|
||||
android:icon="@mipmap/launcher_icon"
|
||||
android:roundIcon="@mipmap/ic_xmas3_round" >
|
||||
|
||||
<intent-filter>
|
||||
|
||||
@@ -1,99 +1,5 @@
|
||||
package app.firka.naplo
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import org.brotli.dec.BrotliInputStream
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class AppMain : Application() {
|
||||
|
||||
private fun File.sha256(): String {
|
||||
if (!exists()) return "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
val digest = md.digest(this.readBytes())
|
||||
return digest.fold("") { str, it -> str + "%02x".format(it) }
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
var useUncompressedLibs = false
|
||||
|
||||
val abi = Build.SUPPORTED_ABIS[0]
|
||||
|
||||
val apks = File(applicationInfo.nativeLibraryDir, "../..").absoluteFile
|
||||
.listFiles()!!
|
||||
.filter { file -> file.name.endsWith(".apk") }
|
||||
.toList()
|
||||
|
||||
var nativesApkN: ZipFile? = null
|
||||
for (apk in apks) {
|
||||
if (nativesApkN != null) break
|
||||
|
||||
val zip = ZipFile(apk)
|
||||
val entries = zip.entries()
|
||||
|
||||
while (entries.hasMoreElements()) {
|
||||
val entry = entries.nextElement()
|
||||
|
||||
if (entry.name.endsWith("$abi/index.so")) {
|
||||
zip.close()
|
||||
nativesApkN = ZipFile(apk)
|
||||
break
|
||||
}
|
||||
if (entry.name.endsWith("$abi/libflutter.so")) {
|
||||
useUncompressedLibs = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
zip.close()
|
||||
}
|
||||
|
||||
if (useUncompressedLibs) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nativesApkN == null) {
|
||||
throw Exception("Can't find native libraries")
|
||||
}
|
||||
val nativesApk: ZipFile = nativesApkN
|
||||
|
||||
val compressedLibsIndex = nativesApk.getInputStream(
|
||||
nativesApk.getEntry("lib/$abi/index.so")
|
||||
)
|
||||
val compressedLibs = JSONObject(compressedLibsIndex.readBytes().toString(Charsets.UTF_8))
|
||||
|
||||
for (so in compressedLibs.keys()) {
|
||||
val soFile = File(cacheDir, so)
|
||||
|
||||
if (soFile.sha256() == compressedLibs.getString(so)) {
|
||||
System.load(soFile.absolutePath)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d("AppMain", "Decompressing: $so")
|
||||
val brInput = nativesApk.getInputStream(
|
||||
nativesApk.getEntry("lib/$abi/${so.replace(".so", "-br.so")}")
|
||||
)
|
||||
val soOutput = FileOutputStream(soFile)
|
||||
|
||||
val brIn = BrotliInputStream(brInput)
|
||||
brIn.copyTo(soOutput)
|
||||
|
||||
brInput.close()
|
||||
soOutput.close()
|
||||
|
||||
System.load(soFile.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
class AppMain : Application() {}
|
||||
@@ -1,19 +1,28 @@
|
||||
package app.firka.naplo
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import app.firka.naplo.glance.TimetableWidget
|
||||
import app.firka.naplo.glance.TimetableWidgetReceiver
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
private val channel = "firka.app/main"
|
||||
private val wearSyncChannel = "app.firka/wear_sync"
|
||||
|
||||
private fun forceIconUpdate() {
|
||||
try {
|
||||
@@ -30,6 +39,57 @@ class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, wearSyncChannel).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"startWearSyncService" -> {
|
||||
val args = call.arguments as? Map<*, *>
|
||||
val cachePath = args?.get("cachePath") as? String
|
||||
val appDirPath = args?.get("appDirPath") as? String
|
||||
if (cachePath != null && appDirPath != null) {
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
val ch = MethodChannel(messenger, wearSyncChannel)
|
||||
ch.invokeMethod("getLocalizedString", "wearSyncNotificationTitle", object : MethodChannel.Result {
|
||||
override fun success(titleResult: Any?) {
|
||||
val title = titleResult as? String ?: "Syncing with watch"
|
||||
ch.invokeMethod("getLocalizedString", "wearSyncNotificationText", object : MethodChannel.Result {
|
||||
override fun success(textResult: Any?) {
|
||||
val text = textResult as? String ?: ""
|
||||
val intent = Intent(this@MainActivity, WearSyncForegroundService::class.java).apply {
|
||||
action = WearSyncForegroundService.ACTION_START
|
||||
putExtra(WearSyncForegroundService.EXTRA_CACHE_PATH, cachePath)
|
||||
putExtra(WearSyncForegroundService.EXTRA_APP_DIR_PATH, appDirPath)
|
||||
putExtra(WearSyncForegroundService.EXTRA_NOTIFICATION_TITLE, title)
|
||||
putExtra(WearSyncForegroundService.EXTRA_NOTIFICATION_TEXT, text)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
override fun error(code: String, msg: String?, details: Any?) { result.success(null) }
|
||||
override fun notImplemented() { result.success(null) }
|
||||
})
|
||||
}
|
||||
override fun error(code: String, msg: String?, details: Any?) { result.error(code, msg, details) }
|
||||
override fun notImplemented() { result.notImplemented() }
|
||||
})
|
||||
} else {
|
||||
result.error("INVALID_ARGS", "cachePath and appDirPath required", null)
|
||||
}
|
||||
}
|
||||
"stopWearSyncService" -> {
|
||||
val intent = Intent(this, WearSyncForegroundService::class.java).apply {
|
||||
action = WearSyncForegroundService.ACTION_STOP
|
||||
}
|
||||
startService(intent)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel).setMethodCallHandler {
|
||||
call, result ->
|
||||
when (call.method) {
|
||||
@@ -97,7 +157,29 @@ class MainActivity : FlutterActivity() {
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
result.success(true)
|
||||
result.success(true)
|
||||
}
|
||||
"refreshTimetableWidget" -> {
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||
try {
|
||||
val appContext = context.applicationContext
|
||||
val appWidgetManager = AppWidgetManager.getInstance(appContext)
|
||||
val componentName = ComponentName(appContext, TimetableWidgetReceiver::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
if (ids.isNotEmpty()) {
|
||||
val intent = Intent(appContext, TimetableWidgetReceiver::class.java).apply {
|
||||
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
|
||||
addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
|
||||
}
|
||||
appContext.sendBroadcast(intent)
|
||||
}
|
||||
TimetableWidget().updateAll(appContext)
|
||||
result.success(true)
|
||||
} catch (e: Exception) {
|
||||
result.error("refresh_failed", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package app.firka.naplo
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ObjectInputStream
|
||||
import com.google.android.gms.wearable.MessageClient
|
||||
import com.google.android.gms.wearable.MessageEvent
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the app able to respond to Wear OS sync requests.
|
||||
* When the watch sends request_sync, starts a Dart background isolate to fetch data,
|
||||
* then reads the cache file and sends sync_data to the watch.
|
||||
*/
|
||||
class WearSyncForegroundService : Service(), MessageClient.OnMessageReceivedListener {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private var cachePath: String? = null
|
||||
private var appDirPath: String? = null
|
||||
|
||||
private val channelId = "firka_wear_sync"
|
||||
private val notificationId = 4001
|
||||
private var notificationTitle: String = "Syncing with watch"
|
||||
private var notificationText: String = ""
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> {
|
||||
cachePath = intent.getStringExtra(EXTRA_CACHE_PATH)
|
||||
appDirPath = intent.getStringExtra(EXTRA_APP_DIR_PATH)
|
||||
notificationTitle = intent.getStringExtra(EXTRA_NOTIFICATION_TITLE) ?: "Syncing with watch"
|
||||
notificationText = intent.getStringExtra(EXTRA_NOTIFICATION_TEXT) ?: ""
|
||||
startForegroundWithNotification()
|
||||
Wearable.getMessageClient(this@WearSyncForegroundService)
|
||||
.addListener(this@WearSyncForegroundService)
|
||||
}
|
||||
ACTION_STOP -> {
|
||||
stopForegroundService()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
Wearable.getMessageClient(this@WearSyncForegroundService)
|
||||
.removeListener(this@WearSyncForegroundService)
|
||||
.addOnCompleteListener { }
|
||||
} catch (_: Exception) { }
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onMessageReceived(messageEvent: MessageEvent) {
|
||||
if (messageEvent.path != PATH_WATCH_CONNECTIVITY ||
|
||||
!isRequestSyncPayload(messageEvent.data)
|
||||
) return
|
||||
val cPath = cachePath
|
||||
val aPath = appDirPath
|
||||
if (cPath == null || aPath == null) return
|
||||
scope.launch {
|
||||
runSyncInBackground(cPath, aPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* watch_connectivity plugin sends with path "watch_connectivity" and serializes the message
|
||||
* map with Java ObjectOutputStream. Parse payload and check for id == "request_sync".
|
||||
*/
|
||||
private fun isRequestSyncPayload(data: ByteArray?): Boolean {
|
||||
if (data == null || data.isEmpty()) return false
|
||||
return try {
|
||||
ObjectInputStream(ByteArrayInputStream(data)).use { ois ->
|
||||
val map = ois.readObject()
|
||||
if (map is Map<*, *>) map["id"] == "request_sync" else false
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundWithNotification() {
|
||||
val notification = buildNotification()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(notificationId, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startForeground(notificationId, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
packageManager.getLaunchIntentForPackage(packageName),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Builder(this, channelId)
|
||||
.setContentTitle(notificationTitle)
|
||||
.setContentText(notificationText)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
"Wear sync",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply { setShowBadge(false) }
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopForegroundService() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private suspend fun runSyncInBackground(cPath: String, aPath: String) = withContext(Dispatchers.Default) {
|
||||
val flutterLoader = FlutterLoader()
|
||||
if (!flutterLoader.initialized()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
flutterLoader.startInitialization(applicationContext)
|
||||
flutterLoader.ensureInitializationComplete(applicationContext, null)
|
||||
}
|
||||
}
|
||||
val (engine, bgChannel) = withContext(Dispatchers.Main) {
|
||||
val eng = FlutterEngine(applicationContext)
|
||||
val entrypoint = DartExecutor.DartEntrypoint(
|
||||
flutterLoader.findAppBundlePath(),
|
||||
"package:firka/services/wear_sync_background.dart",
|
||||
"wearSyncBackgroundEntrypoint"
|
||||
)
|
||||
eng.dartExecutor.executeDartEntrypoint(entrypoint)
|
||||
val ch = MethodChannel(eng.dartExecutor.binaryMessenger, "app.firka/wear_sync_background")
|
||||
Pair(eng, ch)
|
||||
}
|
||||
val completer = CompletableDeferred<Unit>()
|
||||
delay(500)
|
||||
withContext(Dispatchers.Main) {
|
||||
bgChannel.invokeMethod("request_sync", mapOf(
|
||||
"cachePath" to cPath,
|
||||
"appDirPath" to aPath
|
||||
), object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
completer.complete(Unit)
|
||||
}
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
Log.e(TAG, "request_sync error: $errorCode $errorMessage")
|
||||
completer.complete(Unit)
|
||||
}
|
||||
override fun notImplemented() {
|
||||
completer.complete(Unit)
|
||||
}
|
||||
})
|
||||
}
|
||||
try {
|
||||
withTimeout(30_000) {
|
||||
completer.await()
|
||||
}
|
||||
} catch (_: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
Log.w(TAG, "Wear sync isolate timed out")
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
engine.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WearSyncService"
|
||||
const val ACTION_START = "app.firka.naplo.WearSyncForegroundService.START"
|
||||
const val ACTION_STOP = "app.firka.naplo.WearSyncForegroundService.STOP"
|
||||
const val EXTRA_CACHE_PATH = "cachePath"
|
||||
const val EXTRA_APP_DIR_PATH = "appDirPath"
|
||||
const val EXTRA_NOTIFICATION_TITLE = "notificationTitle"
|
||||
const val EXTRA_NOTIFICATION_TEXT = "notificationText"
|
||||
private const val PATH_WATCH_CONNECTIVITY = "watch_connectivity"
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,10 @@ import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.width
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import app.firka.naplo.model.Colors
|
||||
import app.firka.naplo.model.Lesson
|
||||
import app.firka.naplo.glance.WidgetLesson
|
||||
import java.time.format.DateTimeFormatterBuilder
|
||||
|
||||
val hhmm = DateTimeFormatterBuilder()
|
||||
@@ -26,8 +27,13 @@ val hhmm = DateTimeFormatterBuilder()
|
||||
.toFormatter()
|
||||
|
||||
@Composable
|
||||
fun LessonCard(lesson: Lesson, colors: Colors,
|
||||
modifier: GlanceModifier = GlanceModifier) {
|
||||
fun LessonCard(
|
||||
lesson: WidgetLesson,
|
||||
colors: Colors,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
roomBadgeWidthDp: Float = 48f,
|
||||
subjectColumnWidthDp: Float = 226f,
|
||||
) {
|
||||
Box(modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
@@ -38,17 +44,33 @@ fun LessonCard(lesson: Lesson, colors: Colors,
|
||||
var bgColor = colors.a15p
|
||||
var fgColor = colors.textSecondary
|
||||
|
||||
if (lesson.substituteTeacher == null) {
|
||||
if (lesson.substituteTeacher != null) {
|
||||
bgColor = colors.warning15p
|
||||
fgColor = colors.warningText
|
||||
}
|
||||
|
||||
|
||||
Box(modifier = GlanceModifier.padding(12.dp)) {
|
||||
Row {
|
||||
Row(modifier = GlanceModifier.width(226.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
val badgeStyle = TextStyle(
|
||||
color = ColorProvider(colors.textSecondary, colors.textSecondary),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
val badgePadding = GlanceModifier.padding(8.dp, 4.dp)
|
||||
val lessonNumberBadgeModifier = GlanceModifier.cornerRadius(16.dp).width(24.dp)
|
||||
val roomBadgeModifier = GlanceModifier.cornerRadius(16.dp).width(roomBadgeWidthDp.dp)
|
||||
|
||||
Row(
|
||||
modifier = GlanceModifier.width(subjectColumnWidthDp.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (lesson.lessonNumber != null) {
|
||||
Box(modifier = GlanceModifier.cornerRadius(16.dp).background(bgColor)) {
|
||||
Box(
|
||||
modifier = lessonNumberBadgeModifier.background(bgColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
lesson.lessonNumber.toString(),
|
||||
style = TextStyle(
|
||||
@@ -56,24 +78,24 @@ fun LessonCard(lesson: Lesson, colors: Colors,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = GlanceModifier.padding(8.dp, 4.dp),
|
||||
modifier = GlanceModifier.padding(4.dp, 4.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = GlanceModifier.width(4.dp))
|
||||
}
|
||||
// TODO: Add subject icons
|
||||
Text(
|
||||
lesson.name,
|
||||
text = lesson.name,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(colors.textPrimary, colors.textPrimary),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// Spacer(modifier = GlanceModifier.width(10.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
lesson.start.format(hhmm),
|
||||
@@ -84,24 +106,18 @@ fun LessonCard(lesson: Lesson, colors: Colors,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = GlanceModifier.width(8.dp))
|
||||
Box(modifier = GlanceModifier.cornerRadius(16.dp).background(colors.a15p)) {
|
||||
var roomName = "N/A";
|
||||
if (lesson.roomName != null) {
|
||||
roomName = lesson.roomName!!;
|
||||
}
|
||||
|
||||
if (roomName.length < 2) {
|
||||
roomName = " $roomName"
|
||||
}
|
||||
|
||||
val roomName = lesson.roomName ?: "N/A"
|
||||
Box(
|
||||
modifier = roomBadgeModifier.background(colors.a15p),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
roomName,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(colors.textSecondary, colors.textSecondary),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = GlanceModifier.padding(8.dp, 4.dp),
|
||||
text = roomName,
|
||||
style = badgeStyle,
|
||||
maxLines = 1,
|
||||
modifier = GlanceModifier
|
||||
.fillMaxWidth()
|
||||
.padding(4.dp, 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.background
|
||||
import androidx.glance.color.ColorProvider
|
||||
import androidx.glance.currentState
|
||||
@@ -26,7 +28,9 @@ import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import app.firka.naplo.model.Colors
|
||||
import app.firka.naplo.model.Lesson
|
||||
import app.firka.naplo.glance.WidgetLesson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
@@ -37,20 +41,51 @@ class TimetableWidget : GlanceAppWidget() {
|
||||
override val stateDefinition: GlanceStateDefinition<*>?
|
||||
get() = HomeWidgetGlanceStateDefinition()
|
||||
|
||||
override val sizeMode: SizeMode
|
||||
get() = SizeMode.Exact
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = withContext(Dispatchers.IO) {
|
||||
loadWidgetData(context)
|
||||
}
|
||||
provideContent {
|
||||
GlanceContent(context, currentState())
|
||||
GlanceContent(context, currentState(), data)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState) {
|
||||
private fun loadWidgetData(context: Context): WidgetData? {
|
||||
val appFlutter = File(context.applicationContext.dataDir, "app_flutter")
|
||||
val widgetStateFile = File(appFlutter, "widget_state.json")
|
||||
if (!widgetStateFile.exists()) return null
|
||||
val widgetState = JSONObject(widgetStateFile.readText(Charsets.UTF_8))
|
||||
val colors = Colors(widgetState)
|
||||
val tt = widgetState.getJSONArray("timetable")
|
||||
val lessons = mutableListOf<WidgetLesson>()
|
||||
for (i in 0..<tt.length()) {
|
||||
lessons.add(WidgetLesson(tt.getJSONObject(i)))
|
||||
}
|
||||
val displayDateStr = widgetState.optString("displayDate", "")
|
||||
val targetDate = if (displayDateStr.isNotEmpty()) {
|
||||
try {
|
||||
LocalDate.parse(displayDateStr)
|
||||
} catch (_: Exception) {
|
||||
LocalDate.now()
|
||||
}
|
||||
} else {
|
||||
LocalDate.now()
|
||||
}
|
||||
val start = LocalDateTime.of(targetDate.year, targetDate.month, targetDate.dayOfMonth, 0, 0)
|
||||
val end = start.plusHours(23)
|
||||
val filtered = lessons.filter { it.start.isAfter(start) && it.end.isBefore(end) }
|
||||
val headerText = if (displayDateStr.isNotEmpty()) displayDateStr else "Mai órarend"
|
||||
return WidgetData(colors, headerText, filtered)
|
||||
}
|
||||
|
||||
if (!widgetStateFile.exists()) {
|
||||
Box(modifier =
|
||||
GlanceModifier
|
||||
@Composable
|
||||
private fun GlanceContent(context: Context, currentState: HomeWidgetGlanceState, data: WidgetData?) {
|
||||
if (data == null) {
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.background(Color(0xFFFAFFF0))
|
||||
.padding(16.dp)
|
||||
.fillMaxSize(),
|
||||
@@ -65,47 +100,74 @@ class TimetableWidget : GlanceAppWidget() {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
val widgetState = JSONObject(widgetStateFile.readText(Charsets.UTF_8))
|
||||
val colors = Colors(widgetState)
|
||||
|
||||
val tt = widgetState.getJSONArray("timetable")
|
||||
var lessons = mutableListOf<Lesson>()
|
||||
|
||||
for (i in 0..<tt.length()) {
|
||||
lessons.add(Lesson(tt.getJSONObject(i)))
|
||||
val size = LocalSize.current
|
||||
val lessonRowHeightDp = 52f
|
||||
val scale = lessonRowHeightDp / 52f
|
||||
val headerHeightDp = 20f * scale
|
||||
val verticalPaddingDp = 32f * scale
|
||||
val spacerDp = 4f * scale
|
||||
val paddingDp = 16f * scale
|
||||
val availableHeightDp = size.height.value - verticalPaddingDp - headerHeightDp - spacerDp
|
||||
val maxVisibleLessons = (availableHeightDp / lessonRowHeightDp).toInt().coerceAtLeast(0)
|
||||
val maxLessons = (maxVisibleLessons.coerceAtMost(16) / 2 * 2).coerceAtLeast(1)
|
||||
val displayLessons = data.lessons.take(maxLessons)
|
||||
val lessonChunks = displayLessons.chunked(2)
|
||||
val showDate = maxLessons > 1
|
||||
val roomBadgeWidthDp = 48f
|
||||
val minWidthForTimeAndChipDp = 8f + 40f + roomBadgeWidthDp
|
||||
val subjectColumnWidthDp = (size.width.value - 2 * paddingDp - 32f - minWidthForTimeAndChipDp)
|
||||
.coerceIn(80f, 226f)
|
||||
val dateSectionHeight = if (showDate) headerHeightDp + spacerDp else 0f
|
||||
val lessonListHeight = when (val n = displayLessons.size) {
|
||||
0 -> 0f
|
||||
else -> n * lessonRowHeightDp + (n - 1) * spacerDp
|
||||
}
|
||||
val remainingHeight = (size.height.value - 2 * paddingDp - dateSectionHeight - lessonListHeight).coerceAtLeast(0f)
|
||||
val verticalPaddingAroundLessons = remainingHeight / 2f
|
||||
|
||||
val now = LocalDate.now()
|
||||
val start = LocalDateTime.of(now.year, now.month, now.dayOfMonth, 0, 0)
|
||||
val end = start.plusHours(23)
|
||||
lessons = lessons.filter { lesson -> lesson.start.isAfter(start) && lesson.end.isBefore(end) }.toMutableList()
|
||||
|
||||
Box(modifier =
|
||||
GlanceModifier
|
||||
.background(colors.background)
|
||||
.padding(16.dp)
|
||||
Box(
|
||||
modifier = GlanceModifier
|
||||
.background(data.colors.background)
|
||||
.padding(paddingDp.dp)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"Mai órarend",
|
||||
style = TextStyle(
|
||||
color = ColorProvider(colors.textSecondary, colors.textSecondary),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
if (showDate) {
|
||||
Text(
|
||||
data.headerText,
|
||||
style = TextStyle(
|
||||
color = ColorProvider(data.colors.textSecondary, data.colors.textSecondary),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
)
|
||||
)
|
||||
Spacer(modifier = GlanceModifier.height(4.dp))
|
||||
for (lesson in lessons) {
|
||||
LessonCard(lesson, colors)
|
||||
Spacer(modifier = GlanceModifier.height(4.dp))
|
||||
Spacer(modifier = GlanceModifier.height(spacerDp.dp))
|
||||
}
|
||||
Spacer(modifier = GlanceModifier.height(verticalPaddingAroundLessons.dp))
|
||||
for (chunk in lessonChunks) {
|
||||
Column {
|
||||
for (lesson in chunk) {
|
||||
LessonCard(
|
||||
lesson,
|
||||
data.colors,
|
||||
roomBadgeWidthDp = roomBadgeWidthDp,
|
||||
subjectColumnWidthDp = subjectColumnWidthDp,
|
||||
)
|
||||
Spacer(modifier = GlanceModifier.height(spacerDp.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = GlanceModifier.height(verticalPaddingAroundLessons.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
private data class WidgetData(
|
||||
val colors: Colors,
|
||||
val headerText: String,
|
||||
val lessons: List<WidgetLesson>,
|
||||
)
|
||||
@@ -1,7 +1,25 @@
|
||||
package app.firka.naplo.glance
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import HomeWidgetGlanceWidgetReceiver
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class TimetableWidgetReceiver : HomeWidgetGlanceWidgetReceiver<TimetableWidget>() {
|
||||
override val glanceAppWidget = TimetableWidget()
|
||||
|
||||
override fun onAppWidgetOptionsChanged(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetId: Int,
|
||||
newOptions: Bundle,
|
||||
) {
|
||||
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
|
||||
runBlocking {
|
||||
val glanceId = GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId)
|
||||
glanceAppWidget.update(context, glanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app.firka.naplo.glance
|
||||
|
||||
import app.firka.naplo.getIntOrNull
|
||||
import app.firka.naplo.getStringOrNull
|
||||
import org.json.JSONObject
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatterBuilder
|
||||
|
||||
class WidgetLesson(data: JSONObject) {
|
||||
val formatter = DateTimeFormatterBuilder()
|
||||
.appendPattern("yyyy-MM-dd'T'HH:mm:ss.SSS")
|
||||
.optionalStart()
|
||||
.appendLiteral('Z')
|
||||
.optionalEnd()
|
||||
.toFormatter()
|
||||
|
||||
val name: String = data.getString("Nev")
|
||||
val start: LocalDateTime = LocalDateTime.parse(data.getString("KezdetIdopont"), formatter)
|
||||
val end: LocalDateTime = LocalDateTime.parse(data.getString("VegIdopont"), formatter)
|
||||
val lessonNumber: Int? = data.getIntOrNull("Oraszam")
|
||||
val roomName: String? = data.getStringOrNull("TeremNeve")
|
||||
val substituteTeacher: String? = data.getStringOrNull("HelyettesTanarNeve")
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 13 KiB |
@@ -8,6 +8,7 @@
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#7ca120</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#7ca120</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#7ca120</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">#7ca120</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="300dp"
|
||||
android:minHeight="100dp"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="93dp"
|
||||
android:minResizeWidth="180dp"
|
||||
android:minResizeHeight="93dp"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="10000">
|
||||
android:updatePeriodMillis="1800000">
|
||||
</appwidget-provider>
|
||||
@@ -22,8 +22,8 @@ subprojects {
|
||||
if (plugins.hasPlugin("com.android.application") || plugins.hasPlugin("com.android.library")) {
|
||||
val androidExtension = extensions.getByName("android") as BaseExtension
|
||||
androidExtension.apply {
|
||||
compileSdkVersion(35)
|
||||
buildToolsVersion = "35.0.0"
|
||||
compileSdkVersion(37)
|
||||
buildToolsVersion = "36.1.0"
|
||||
}
|
||||
}
|
||||
if (hasProperty("android")) {
|
||||
@@ -40,9 +40,6 @@ subprojects {
|
||||
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# Disabled for faster config and incremental builds; re-enable if any dependency needs support-library
|
||||
android.enableJetifier=false
|
||||
|
||||
# Build performance (cold and warm)
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
# Configuration cache disabled: Flutter/AGP/Kotlin plugin not fully compatible (KotlinBaseApiPlugin / ProjectServices)
|
||||
# org.gradle.configuration-cache=true
|
||||
org.gradle.daemon=true
|
||||
# Better Kotlin incremental compilation (warm builds)
|
||||
kotlin.incremental.useClasspathSnapshot=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
|
||||
android.newDsl=false
|
||||
|
||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-all.zip
|
||||
|
||||
@@ -18,8 +18,9 @@ pluginManagement {
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.7.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
|
||||
id("com.android.application") version "9.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.10" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
BIN
firka/assets/icons/button/colorwheel.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
15
firka/assets/icons/dave.svg
Normal file
|
After Width: | Height: | Size: 18 KiB |
14
firka/assets/images/carousel/card1.svg
Normal file
|
After Width: | Height: | Size: 15 KiB |
6
firka/assets/images/carousel/card2.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="108" height="48" viewBox="0 0 108 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="108" height="48" rx="12" fill="#F3FBDE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.6555 15.6657C30.968 15.3533 31.3919 15.1777 31.8338 15.1777C32.2758 15.1777 32.6996 15.3533 33.0122 15.6657L34.3338 16.9874C34.6463 17.2999 34.8218 17.7238 34.8218 18.1657C34.8218 18.6077 34.6463 19.0315 34.3338 19.344L33.0122 20.6657L29.3338 16.9874L30.6555 15.6657ZM28.1555 18.1657L23.9888 22.3324C23.6762 22.6449 23.5006 23.0687 23.5005 23.5107V24.8324C23.5005 25.2744 23.6761 25.6983 23.9886 26.0109C24.3012 26.3235 24.7251 26.499 25.1672 26.499H26.4888C26.9308 26.499 27.3547 26.3233 27.6672 26.0107L31.8338 21.844L28.1555 18.1657Z" fill="#A7DC22"/>
|
||||
<path d="M21.0005 25.666H20.1672C19.7251 25.666 19.3012 25.8416 18.9886 26.1542C18.6761 26.4667 18.5005 26.8907 18.5005 27.3327C18.5005 27.7747 18.6761 28.1986 18.9886 28.5112C19.3012 28.8238 19.7251 28.9993 20.1672 28.9993H31.8338C32.2758 28.9993 32.6998 29.1749 33.0123 29.4875C33.3249 29.8001 33.5005 30.224 33.5005 30.666C33.5005 31.108 33.3249 31.532 33.0123 31.8445C32.6998 32.1571 32.2758 32.3327 31.8338 32.3327H28.5005" stroke="#A7DC22" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M47.648 29.5V19.996H44.208V18.3H52.944V19.996H49.504V29.5H47.648ZM56.7006 29.692C55.922 29.692 55.2286 29.516 54.6206 29.164C54.0233 28.8013 53.5486 28.3053 53.1966 27.676C52.8553 27.0467 52.6846 26.3213 52.6846 25.5C52.6846 24.6787 52.8606 23.9533 53.2126 23.324C53.5646 22.6947 54.0446 22.204 54.6526 21.852C55.2713 21.4893 55.9753 21.308 56.7646 21.308C57.4793 21.308 58.1246 21.4947 58.7006 21.868C59.2766 22.2307 59.73 22.7587 60.0606 23.452C60.402 24.1453 60.5726 24.972 60.5726 25.932H54.2846L54.5246 25.708C54.5246 26.1987 54.6313 26.6253 54.8446 26.988C55.058 27.34 55.3406 27.612 55.6926 27.804C56.0446 27.996 56.434 28.092 56.8606 28.092C57.3513 28.092 57.7566 27.9853 58.0766 27.772C58.3966 27.548 58.6473 27.26 58.8286 26.908L60.4126 27.58C60.1886 28.0067 59.9006 28.38 59.5486 28.7C59.2073 29.02 58.7966 29.2653 58.3166 29.436C57.8473 29.6067 57.3086 29.692 56.7006 29.692ZM54.6366 24.78L54.3806 24.556H58.8926L58.6526 24.78C58.6526 24.3427 58.5566 23.9853 58.3646 23.708C58.1726 23.42 57.9273 23.2067 57.6286 23.068C57.3406 22.9187 57.0366 22.844 56.7166 22.844C56.3966 22.844 56.0766 22.9187 55.7566 23.068C55.4366 23.2067 55.17 23.42 54.9566 23.708C54.7433 23.9853 54.6366 24.3427 54.6366 24.78ZM55.5326 20.46L57.3886 18.3H59.4686L57.4046 20.46H55.5326ZM61.9764 29.5V21.5H63.6564L63.7364 22.572C63.9817 22.156 64.2964 21.8413 64.6804 21.628C65.0644 21.4147 65.5017 21.308 65.9924 21.308C66.6324 21.308 67.1764 21.452 67.6244 21.74C68.0724 22.028 68.3977 22.4653 68.6004 23.052C68.835 22.4867 69.1657 22.0547 69.5924 21.756C70.019 21.4573 70.5204 21.308 71.0964 21.308C72.0244 21.308 72.739 21.6067 73.2404 22.204C73.7417 22.7907 73.987 23.6973 73.9764 24.924V29.5H72.2004V25.404C72.2004 24.764 72.131 24.2733 71.9924 23.932C71.8537 23.58 71.667 23.3347 71.4324 23.196C71.1977 23.0573 70.9257 22.988 70.6164 22.988C70.0617 22.9773 69.6297 23.1747 69.3204 23.58C69.0217 23.9853 68.8724 24.5667 68.8724 25.324V29.5H67.0804V25.404C67.0804 24.764 67.011 24.2733 66.8724 23.932C66.7444 23.58 66.563 23.3347 66.3284 23.196C66.0937 23.0573 65.8217 22.988 65.5124 22.988C64.9577 22.9773 64.5257 23.1747 64.2164 23.58C63.9177 23.9853 63.7684 24.5667 63.7684 25.324V29.5H61.9764ZM80.6845 29.5L80.6045 27.996V25.388C80.6045 24.844 80.5458 24.3907 80.4285 24.028C80.3218 23.6547 80.1405 23.372 79.8845 23.18C79.6392 22.9773 79.3085 22.876 78.8925 22.876C78.5085 22.876 78.1725 22.956 77.8845 23.116C77.5965 23.276 77.3512 23.5267 77.1485 23.868L75.5805 23.292C75.7512 22.94 75.9752 22.6147 76.2525 22.316C76.5405 22.0067 76.8978 21.7613 77.3245 21.58C77.7618 21.3987 78.2845 21.308 78.8925 21.308C79.6712 21.308 80.3218 21.4627 80.8445 21.772C81.3672 22.0707 81.7512 22.5027 81.9965 23.068C82.2525 23.6333 82.3805 24.316 82.3805 25.116L82.3325 29.5H80.6845ZM78.3805 29.692C77.4205 29.692 76.6738 29.4787 76.1405 29.052C75.6178 28.6253 75.3565 28.0227 75.3565 27.244C75.3565 26.412 75.6338 25.7773 76.1885 25.34C76.7538 24.9027 77.5378 24.684 78.5405 24.684H80.6845V26.06H79.1165C78.4018 26.06 77.9005 26.1613 77.6125 26.364C77.3245 26.556 77.1805 26.8333 77.1805 27.196C77.1805 27.5053 77.3032 27.7507 77.5485 27.932C77.8045 28.1027 78.1565 28.188 78.6045 28.188C79.0098 28.188 79.3618 28.0973 79.6605 27.916C79.9592 27.7347 80.1885 27.4947 80.3485 27.196C80.5192 26.8973 80.6045 26.5613 80.6045 26.188H81.1325C81.1325 27.276 80.9138 28.1347 80.4765 28.764C80.0392 29.3827 79.3405 29.692 78.3805 29.692ZM77.5965 20.46L79.4525 18.3H81.5325L79.4685 20.46H77.5965ZM85.829 27.26L84.741 26.028L88.901 21.5H91.061L85.829 27.26ZM84.117 29.5V18.3H85.909V29.5H84.117ZM89.205 29.5L86.389 25.356L87.557 24.108L91.333 29.5H89.205Z" fill="#394C0A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
5
firka/assets/images/carousel/card3.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
5
firka/assets/images/carousel/card4.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="108" height="48" viewBox="0 0 108 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="108" height="48" rx="12" fill="#F3FBDE"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.8026 15.9055C26.7524 15.8382 26.6891 15.7817 26.6166 15.7394C26.544 15.6971 26.4637 15.6699 26.3804 15.6593C26.2971 15.6487 26.2125 15.655 26.1317 15.6778C26.0509 15.7007 25.9755 15.7396 25.9101 15.7922C24.3129 17.0742 23.2594 18.9127 22.9609 20.9388C22.4141 20.5413 21.9344 20.0589 21.5401 19.5097C21.4866 19.4349 21.4173 19.3729 21.3371 19.328C21.2569 19.2831 21.1678 19.2564 21.0761 19.2499C20.9845 19.2434 20.8925 19.2572 20.8067 19.2902C20.721 19.3233 20.6436 19.3749 20.5801 19.4413C19.4795 20.5926 18.774 22.0644 18.5658 23.6435C18.3576 25.2225 18.6575 26.8268 19.4221 28.2241C20.1866 29.6213 21.3761 30.7388 22.8182 31.4148C24.2604 32.0908 25.8803 32.2902 27.4433 31.984C29.0063 31.6778 30.4312 30.882 31.5117 29.7118C32.5922 28.5416 33.2721 27.0579 33.453 25.4755C33.6338 23.893 33.3062 22.2941 32.5176 20.9104C31.729 19.5266 30.5204 18.4298 29.0667 17.7788C28.1732 17.3443 27.3967 16.7019 26.8026 15.9055ZM29.1251 25.8755C29.1248 26.3275 29.0264 26.7741 28.8367 27.1844C28.6471 27.5948 28.3707 27.959 28.0266 28.2522C27.6825 28.5453 27.2789 28.7603 26.8437 28.8823C26.4084 29.0044 25.9519 29.0305 25.5056 28.959C25.0592 28.8875 24.6337 28.7199 24.2584 28.468C23.8831 28.2161 23.5669 27.8857 23.3317 27.4998C23.0964 27.1138 22.9477 26.6814 22.8958 26.2323C22.8438 25.7833 22.8899 25.3283 23.0309 24.8988C23.5542 25.2863 24.1559 25.5738 24.8084 25.7322C24.986 24.5915 25.5528 23.5475 26.4126 22.7772C27.1634 22.8772 27.8524 23.2465 28.3513 23.8164C28.8503 24.3863 29.1252 25.118 29.1251 25.8755Z" fill="#A7DC22"/>
|
||||
<path d="M49.008 29.692C48.4213 29.692 47.8827 29.6227 47.392 29.484C46.912 29.3453 46.4853 29.1533 46.112 28.908C45.7493 28.6627 45.4453 28.3907 45.2 28.092C44.9653 27.7827 44.8 27.4627 44.704 27.132L46.528 26.572C46.6667 26.9667 46.9387 27.3133 47.344 27.612C47.7493 27.9107 48.2507 28.0653 48.848 28.076C49.5413 28.076 50.0907 27.932 50.496 27.644C50.9013 27.356 51.104 26.9773 51.104 26.508C51.104 26.0813 50.9333 25.7347 50.592 25.468C50.2507 25.1907 49.792 24.9773 49.216 24.828L47.84 24.476C47.3173 24.3373 46.8427 24.1347 46.416 23.868C46 23.6013 45.6693 23.2653 45.424 22.86C45.1893 22.4547 45.072 21.9747 45.072 21.42C45.072 20.3747 45.4133 19.564 46.096 18.988C46.7787 18.4013 47.7547 18.108 49.024 18.108C49.7387 18.108 50.3627 18.22 50.896 18.444C51.44 18.6573 51.888 18.956 52.24 19.34C52.592 19.7133 52.8533 20.14 53.024 20.62L51.232 21.196C51.072 20.7693 50.7947 20.4173 50.4 20.14C50.0053 19.8627 49.5147 19.724 48.928 19.724C48.32 19.724 47.84 19.868 47.488 20.156C47.1467 20.444 46.976 20.844 46.976 21.356C46.976 21.772 47.1093 22.0973 47.376 22.332C47.6533 22.556 48.0267 22.7267 48.496 22.844L49.872 23.18C50.8747 23.4253 51.6533 23.8467 52.208 24.444C52.7627 25.0413 53.04 25.7027 53.04 26.428C53.04 27.068 52.8853 27.6333 52.576 28.124C52.2667 28.6147 51.808 28.9987 51.2 29.276C50.6027 29.5533 49.872 29.692 49.008 29.692ZM57.9416 29.692C57.099 29.692 56.4536 29.484 56.0056 29.068C55.5683 28.6413 55.3496 28.0333 55.3496 27.244V19.004H57.1256V26.908C57.1256 27.2813 57.211 27.564 57.3816 27.756C57.563 27.948 57.8243 28.044 58.1656 28.044C58.2723 28.044 58.3896 28.0227 58.5176 27.98C58.6456 27.9373 58.7896 27.8573 58.9496 27.74L59.6056 29.1C59.3283 29.292 59.051 29.436 58.7736 29.532C58.4963 29.6387 58.219 29.692 57.9416 29.692ZM54.0216 23.036V21.5H59.2856V23.036H54.0216ZM62.2229 25.244C62.2229 24.38 62.3882 23.6707 62.7189 23.116C63.0495 22.5613 63.4762 22.1507 63.9989 21.884C64.5322 21.6067 65.0869 21.468 65.6629 21.468V23.18C65.1722 23.18 64.7082 23.2493 64.2709 23.388C63.8442 23.516 63.4975 23.7293 63.2309 24.028C62.9642 24.3267 62.8309 24.7213 62.8309 25.212L62.2229 25.244ZM61.0389 29.5V21.5H62.8309V29.5H61.0389ZM70.4194 29.692C69.6407 29.692 68.9474 29.516 68.3394 29.164C67.742 28.8013 67.2674 28.3053 66.9154 27.676C66.574 27.0467 66.4034 26.3213 66.4034 25.5C66.4034 24.6787 66.5794 23.9533 66.9314 23.324C67.2834 22.6947 67.7634 22.204 68.3714 21.852C68.99 21.4893 69.694 21.308 70.4834 21.308C71.198 21.308 71.8434 21.4947 72.4194 21.868C72.9954 22.2307 73.4487 22.7587 73.7794 23.452C74.1207 24.1453 74.2914 24.972 74.2914 25.932H68.0034L68.2434 25.708C68.2434 26.1987 68.35 26.6253 68.5634 26.988C68.7767 27.34 69.0594 27.612 69.4114 27.804C69.7634 27.996 70.1527 28.092 70.5794 28.092C71.07 28.092 71.4754 27.9853 71.7954 27.772C72.1154 27.548 72.366 27.26 72.5474 26.908L74.1314 27.58C73.9074 28.0067 73.6194 28.38 73.2674 28.7C72.926 29.02 72.5154 29.2653 72.0354 29.436C71.566 29.6067 71.0274 29.692 70.4194 29.692ZM68.3554 24.78L68.0994 24.556H72.6114L72.3714 24.78C72.3714 24.3427 72.2754 23.9853 72.0834 23.708C71.8914 23.42 71.646 23.2067 71.3474 23.068C71.0594 22.9187 70.7554 22.844 70.4354 22.844C70.1154 22.844 69.7954 22.9187 69.4754 23.068C69.1554 23.2067 68.8887 23.42 68.6754 23.708C68.462 23.9853 68.3554 24.3427 68.3554 24.78ZM80.5439 29.5L80.4639 27.996V25.388C80.4639 24.844 80.4052 24.3907 80.2879 24.028C80.1812 23.6547 79.9999 23.372 79.7439 23.18C79.4985 22.9773 79.1679 22.876 78.7519 22.876C78.3679 22.876 78.0319 22.956 77.7439 23.116C77.4559 23.276 77.2105 23.5267 77.0079 23.868L75.4399 23.292C75.6105 22.94 75.8345 22.6147 76.1119 22.316C76.3999 22.0067 76.7572 21.7613 77.1839 21.58C77.6212 21.3987 78.1439 21.308 78.7519 21.308C79.5305 21.308 80.1812 21.4627 80.7039 21.772C81.2265 22.0707 81.6105 22.5027 81.8559 23.068C82.1119 23.6333 82.2399 24.316 82.2399 25.116L82.1919 29.5H80.5439ZM78.2399 29.692C77.2799 29.692 76.5332 29.4787 75.9999 29.052C75.4772 28.6253 75.2159 28.0227 75.2159 27.244C75.2159 26.412 75.4932 25.7773 76.0479 25.34C76.6132 24.9027 77.3972 24.684 78.3999 24.684H80.5439V26.06H78.9759C78.2612 26.06 77.7599 26.1613 77.4719 26.364C77.1839 26.556 77.0399 26.8333 77.0399 27.196C77.0399 27.5053 77.1625 27.7507 77.4079 27.932C77.6639 28.1027 78.0159 28.188 78.4639 28.188C78.8692 28.188 79.2212 28.0973 79.5199 27.916C79.8185 27.7347 80.0479 27.4947 80.2079 27.196C80.3785 26.8973 80.4639 26.5613 80.4639 26.188H80.9919C80.9919 27.276 80.7732 28.1347 80.3359 28.764C79.8985 29.3827 79.1999 29.692 78.2399 29.692ZM85.7665 27.26L84.6785 26.028L88.8385 21.5H90.9985L85.7665 27.26ZM84.0545 29.5V18.3H85.8465V29.5H84.0545ZM89.1425 29.5L86.3265 25.356L87.4945 24.108L91.2705 29.5H89.1425Z" fill="#394C0A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
6
firka/assets/images/carousel/card5.svg
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
5
firka/assets/images/carousel/card6.svg
Normal file
|
After Width: | Height: | Size: 20 KiB |
6
firka/assets/images/carousel/card7.svg
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
firka/assets/images/logos/splash_android12.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
4
firka/assets/majesticons/gradeDefault.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="23" height="18" viewBox="0 0 23 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.8252 0L3.09442 6.29374L2.5175 3.93359C3.26925 3.93359 3.88114 4.15212 4.35317 4.58919C4.8252 5.02625 5.06122 5.6294 5.06122 6.39864C5.06122 7.15039 4.81646 7.76228 4.32695 8.23431C3.85492 8.68886 3.26051 8.91613 2.54372 8.91613C1.80945 8.91613 1.19756 8.68886 0.708046 8.23431C0.236015 7.76228 0 7.15039 0 6.39864C0 6.17136 0.0174826 5.95283 0.0524478 5.74304C0.0874131 5.51576 0.157344 5.25352 0.262239 4.95632C0.367135 4.65912 0.515737 4.26576 0.708046 3.77624L2.22903 0H4.8252ZM11.014 0L9.28327 6.29374L8.70634 3.93359C9.45809 3.93359 10.07 4.15212 10.542 4.58919C11.014 5.02625 11.2501 5.6294 11.2501 6.39864C11.2501 7.15039 11.0053 7.76228 10.5158 8.23431C10.0438 8.68886 9.44935 8.91613 8.73256 8.91613C7.99829 8.91613 7.3864 8.68886 6.89689 8.23431C6.42486 7.76228 6.18884 7.15039 6.18884 6.39864C6.18884 6.17136 6.20633 5.95283 6.24129 5.74304C6.27626 5.51576 6.34619 5.25352 6.45108 4.95632C6.55598 4.65912 6.70458 4.26576 6.89689 3.77624L8.41788 0H11.014Z" fill="#A0D025"/>
|
||||
<path d="M17.6748 17.832L19.4056 11.5383L19.9825 13.8984C19.2308 13.8984 18.6189 13.6799 18.1468 13.2428C17.6748 12.8058 17.4388 12.2026 17.4388 11.4334C17.4388 10.6816 17.6835 10.0698 18.1731 9.59772C18.6451 9.14317 19.2395 8.9159 19.9563 8.9159C20.6905 8.9159 21.3024 9.14317 21.792 9.59772C22.264 10.0698 22.5 10.6816 22.5 11.4334C22.5 11.6607 22.4825 11.8792 22.4476 12.089C22.4126 12.3163 22.3427 12.5785 22.2378 12.8757C22.1329 13.1729 21.9843 13.5663 21.792 14.0558L20.271 17.832H17.6748ZM11.486 17.832L13.2167 11.5383L13.7937 13.8984C13.0419 13.8984 12.43 13.6799 11.958 13.2428C11.486 12.8058 11.2499 12.2026 11.2499 11.4334C11.2499 10.6816 11.4947 10.0698 11.9842 9.59772C12.4562 9.14317 13.0506 8.9159 13.7674 8.9159C14.5017 8.9159 15.1136 9.14317 15.6031 9.59772C16.0751 10.0698 16.3112 10.6816 16.3112 11.4334C16.3112 11.6607 16.2937 11.8792 16.2587 12.089C16.2237 12.3163 16.1538 12.5785 16.0489 12.8757C15.944 13.1729 15.7954 13.5663 15.6031 14.0558L14.0821 17.832H11.486Z" fill="#A0D025"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
4
firka/assets/majesticons/homeWithMark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.2187 15.3754C21.5637 14.9441 22.1937 14.8741 22.6249 15.2191C23.0562 15.5641 23.1262 16.1941 22.7812 16.6254L18.7812 21.6254C18.6036 21.8474 18.3394 21.9826 18.0556 21.9984C17.7716 22.0142 17.494 21.9086 17.2929 21.7074L15.2929 19.7074C14.9024 19.3169 14.9024 18.6839 15.2929 18.2934C15.6834 17.9028 16.3164 17.9028 16.707 18.2934L17.9159 19.5023L21.2187 15.3754Z" fill="#A0D025"/>
|
||||
<path d="M10.7998 3.65137C11.146 3.39172 11.5673 3.25098 12 3.25098C12.4327 3.25098 12.854 3.39172 13.2002 3.65137L20.2002 8.90137C20.4484 9.08763 20.6503 9.32886 20.7891 9.60645C20.9279 9.88415 21 10.1905 21 10.501V12.4565C21 12.9796 20.4396 13.3258 19.9346 13.1894C19.4773 13.066 18.9964 13 18.5 13C16.5152 13 14.776 14.0513 13.8089 15.6274C13.6713 15.8515 13.4337 16.001 13.1708 16.001H13H11V19.001C11 19.5314 10.7891 20.04 10.4141 20.415C10.039 20.7901 9.53043 21.001 9 21.001H5C4.46957 21.001 3.96101 20.7901 3.58594 20.415C3.21087 20.04 3 19.5314 3 19.001V10.501C3 10.1905 3.07208 9.88416 3.21094 9.60645C3.34973 9.32885 3.55156 9.08763 3.7998 8.90137L10.7998 3.65137Z" fill="#A0D025"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,3 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 3C13.5 2.73478 13.3946 2.48043 13.2071 2.29289C13.0196 2.10536 12.7652 2 12.5 2C12.2348 2 11.9804 2.10536 11.7929 2.29289C11.6054 2.48043 11.5 2.73478 11.5 3V4C11.5 4.26522 11.6054 4.51957 11.7929 4.70711C11.9804 4.89464 12.2348 5 12.5 5C12.7652 5 13.0196 4.89464 13.2071 4.70711C13.3946 4.51957 13.5 4.26522 13.5 4V3ZM6.207 4.293C6.0184 4.11084 5.7658 4.01005 5.5036 4.01233C5.2414 4.0146 4.99059 4.11977 4.80518 4.30518C4.61977 4.49059 4.5146 4.7414 4.51233 5.0036C4.51005 5.2658 4.61084 5.5184 4.793 5.707L5.793 6.707C5.9816 6.88916 6.2342 6.98995 6.4964 6.98767C6.7586 6.9854 7.00941 6.88023 7.19482 6.69482C7.38023 6.50941 7.4854 6.2586 7.48767 5.9964C7.48995 5.7342 7.38916 5.4816 7.207 5.293L6.207 4.293ZM20.207 4.293C20.0195 4.10553 19.7652 4.00021 19.5 4.00021C19.2348 4.00021 18.9805 4.10553 18.793 4.293L17.793 5.293C17.6108 5.4816 17.51 5.7342 17.5123 5.9964C17.5146 6.2586 17.6198 6.50941 17.8052 6.69482C17.9906 6.88023 18.2414 6.9854 18.5036 6.98767C18.7658 6.98995 19.0184 6.88916 19.207 6.707L20.207 5.707C20.3945 5.51947 20.4998 5.26516 20.4998 5C20.4998 4.73484 20.3945 4.48053 20.207 4.293ZM12.5 7C11.1739 7 9.90215 7.52678 8.96447 8.46447C8.02678 9.40215 7.5 10.6739 7.5 12C7.5 13.3261 8.02678 14.5979 8.96447 15.5355C9.90215 16.4732 11.1739 17 12.5 17C13.8261 17 15.0979 16.4732 16.0355 15.5355C16.9732 14.5979 17.5 13.3261 17.5 12C17.5 10.6739 16.9732 9.40215 16.0355 8.46447C15.0979 7.52678 13.8261 7 12.5 7ZM3.5 11C3.23478 11 2.98043 11.1054 2.79289 11.2929C2.60536 11.4804 2.5 11.7348 2.5 12C2.5 12.2652 2.60536 12.5196 2.79289 12.7071C2.98043 12.8946 3.23478 13 3.5 13H4.5C4.76522 13 5.01957 12.8946 5.20711 12.7071C5.39464 12.5196 5.5 12.2652 5.5 12C5.5 11.7348 5.39464 11.4804 5.20711 11.2929C5.01957 11.1054 4.76522 11 4.5 11H3.5ZM20.5 11C20.2348 11 19.9804 11.1054 19.7929 11.2929C19.6054 11.4804 19.5 11.7348 19.5 12C19.5 12.2652 19.6054 12.5196 19.7929 12.7071C19.9804 12.8946 20.2348 13 20.5 13H21.5C21.7652 13 22.0196 12.8946 22.2071 12.7071C22.3946 12.5196 22.5 12.2652 22.5 12C22.5 11.7348 22.3946 11.4804 22.2071 11.2929C22.0196 11.1054 21.7652 11 21.5 11H20.5ZM7.207 18.707C7.30251 18.6148 7.37869 18.5044 7.4311 18.3824C7.48351 18.2604 7.5111 18.1292 7.51225 17.9964C7.5134 17.8636 7.4881 17.7319 7.43782 17.609C7.38754 17.4862 7.31329 17.3745 7.2194 17.2806C7.1255 17.1867 7.01385 17.1125 6.89095 17.0622C6.76806 17.0119 6.63638 16.9866 6.5036 16.9877C6.37082 16.9889 6.2396 17.0165 6.1176 17.0689C5.99559 17.1213 5.88525 17.1975 5.793 17.293L4.793 18.293C4.69749 18.3852 4.62131 18.4956 4.5689 18.6176C4.51649 18.7396 4.4889 18.8708 4.48775 19.0036C4.4866 19.1364 4.5119 19.2681 4.56218 19.391C4.61246 19.5138 4.68671 19.6255 4.7806 19.7194C4.8745 19.8133 4.98615 19.8875 5.10905 19.9378C5.23194 19.9881 5.36362 20.0134 5.4964 20.0123C5.62918 20.0111 5.7604 19.9835 5.8824 19.9311C6.00441 19.8787 6.11475 19.8025 6.207 19.707L7.207 18.707ZM19.207 17.293C19.0184 17.1108 18.7658 17.01 18.5036 17.0123C18.2414 17.0146 17.9906 17.1198 17.8052 17.3052C17.6198 17.4906 17.5146 17.7414 17.5123 18.0036C17.51 18.2658 17.6108 18.5184 17.793 18.707L18.793 19.707C18.9816 19.8892 19.2342 19.99 19.4964 19.9877C19.7586 19.9854 20.0094 19.8802 20.1948 19.6948C20.3802 19.5094 20.4854 19.2586 20.4877 18.9964C20.49 18.7342 20.3892 18.4816 20.207 18.293L19.207 17.293ZM13.5 20C13.5 19.7348 13.3946 19.4804 13.2071 19.2929C13.0196 19.1054 12.7652 19 12.5 19C12.2348 19 11.9804 19.1054 11.7929 19.2929C11.6054 19.4804 11.5 19.7348 11.5 20V21C11.5 21.2652 11.6054 21.5196 11.7929 21.7071C11.9804 21.8946 12.2348 22 12.5 22C12.7652 22 13.0196 21.8946 13.2071 21.7071C13.3946 21.5196 13.5 21.2652 13.5 21V20Z" fill="#FFFFFF"/>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 3C13 2.73478 12.8946 2.48043 12.7071 2.29289C12.5196 2.10536 12.2652 2 12 2C11.7348 2 11.4804 2.10536 11.2929 2.29289C11.1054 2.48043 11 2.73478 11 3V4C11 4.26522 11.1054 4.51957 11.2929 4.70711C11.4804 4.89464 11.7348 5 12 5C12.2652 5 12.5196 4.89464 12.7071 4.70711C12.8946 4.51957 13 4.26522 13 4V3ZM5.707 4.293C5.5184 4.11084 5.2658 4.01005 5.0036 4.01233C4.7414 4.0146 4.49059 4.11977 4.30518 4.30518C4.11977 4.49059 4.0146 4.7414 4.01233 5.0036C4.01005 5.2658 4.11084 5.5184 4.293 5.707L5.293 6.707C5.4816 6.88916 5.7342 6.98995 5.9964 6.98767C6.2586 6.9854 6.50941 6.88023 6.69482 6.69482C6.88023 6.50941 6.9854 6.2586 6.98767 5.9964C6.98995 5.7342 6.88916 5.4816 6.707 5.293L5.707 4.293ZM19.707 4.293C19.5195 4.10553 19.2652 4.00021 19 4.00021C18.7348 4.00021 18.4805 4.10553 18.293 4.293L17.293 5.293C17.1108 5.4816 17.01 5.7342 17.0123 5.9964C17.0146 6.2586 17.1198 6.50941 17.3052 6.69482C17.4906 6.88023 17.7414 6.9854 18.0036 6.98767C18.2658 6.98995 18.5184 6.88916 18.707 6.707L19.707 5.707C19.8945 5.51947 19.9998 5.26516 19.9998 5C19.9998 4.73484 19.8945 4.48053 19.707 4.293ZM12 7C10.6739 7 9.40215 7.52678 8.46447 8.46447C7.52678 9.40215 7 10.6739 7 12C7 13.3261 7.52678 14.5979 8.46447 15.5355C9.40215 16.4732 10.6739 17 12 17C13.3261 17 14.5979 16.4732 15.5355 15.5355C16.4732 14.5979 17 13.3261 17 12C17 10.6739 16.4732 9.40215 15.5355 8.46447C14.5979 7.52678 13.3261 7 12 7ZM3 11C2.73478 11 2.48043 11.1054 2.29289 11.2929C2.10536 11.4804 2 11.7348 2 12C2 12.2652 2.10536 12.5196 2.29289 12.7071C2.48043 12.8946 2.73478 13 3 13H4C4.26522 13 4.51957 12.8946 4.70711 12.7071C4.89464 12.5196 5 12.2652 5 12C5 11.7348 4.89464 11.4804 4.70711 11.2929C4.51957 11.1054 4.26522 11 4 11H3ZM20 11C19.7348 11 19.4804 11.1054 19.2929 11.2929C19.1054 11.4804 19 11.7348 19 12C19 12.2652 19.1054 12.5196 19.2929 12.7071C19.4804 12.8946 19.7348 13 20 13H21C21.2652 13 21.5196 12.8946 21.7071 12.7071C21.8946 12.5196 22 12.2652 22 12C22 11.7348 21.8946 11.4804 21.7071 11.2929C21.5196 11.1054 21.2652 11 21 11H20ZM6.707 18.707C6.80251 18.6148 6.87869 18.5044 6.9311 18.3824C6.98351 18.2604 7.0111 18.1292 7.01225 17.9964C7.0134 17.8636 6.9881 17.7319 6.93782 17.609C6.88754 17.4862 6.81329 17.3745 6.7194 17.2806C6.6255 17.1867 6.51385 17.1125 6.39095 17.0622C6.26806 17.0119 6.13638 16.9866 6.0036 16.9877C5.87082 16.9889 5.7396 17.0165 5.6176 17.0689C5.49559 17.1213 5.38525 17.1975 5.293 17.293L4.293 18.293C4.19749 18.3852 4.12131 18.4956 4.0689 18.6176C4.01649 18.7396 3.9889 18.8708 3.98775 19.0036C3.9866 19.1364 4.0119 19.2681 4.06218 19.391C4.11246 19.5138 4.18671 19.6255 4.2806 19.7194C4.3745 19.8133 4.48615 19.8875 4.60905 19.9378C4.73194 19.9881 4.86362 20.0134 4.9964 20.0123C5.12918 20.0111 5.2604 19.9835 5.3824 19.9311C5.50441 19.8787 5.61475 19.8025 5.707 19.707L6.707 18.707ZM18.707 17.293C18.5184 17.1108 18.2658 17.01 18.0036 17.0123C17.7414 17.0146 17.4906 17.1198 17.3052 17.3052C17.1198 17.4906 17.0146 17.7414 17.0123 18.0036C17.01 18.2658 17.1108 18.5184 17.293 18.707L18.293 19.707C18.4816 19.8892 18.7342 19.99 18.9964 19.9877C19.2586 19.9854 19.5094 19.8802 19.6948 19.6948C19.8802 19.5094 19.9854 19.2586 19.9877 18.9964C19.99 18.7342 19.8892 18.4816 19.707 18.293L18.707 17.293ZM13 20C13 19.7348 12.8946 19.4804 12.7071 19.2929C12.5196 19.1054 12.2652 19 12 19C11.7348 19 11.4804 19.1054 11.2929 19.2929C11.1054 19.4804 11 19.7348 11 20V21C11 21.2652 11.1054 21.5196 11.2929 21.7071C11.4804 21.8946 11.7348 22 12 22C12.2652 22 12.5196 21.8946 12.7071 21.7071C12.8946 21.5196 13 21.2652 13 21V20Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.6 KiB |
21
firka/codegen-lock.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
icons:
|
||||
"flutter_launcher_icons.yaml": "c600507ca0df7cebd0f708124842512a14ed3d597b779176200d6ba25b1335b1"
|
||||
"pubspec.yaml": "a6ae0bd67a2b6226bec2dfd55437b3db2b7ca4a03f315c1a0a8c2f4b505c7c87"
|
||||
"assets/images/logos/colored_logo.webp": "4b4fa99d144fe6694aa4487ba1b26aeecafae41e3c877836cd7da28d61a77983"
|
||||
"assets/images/logos/monochrome_logo.png": "188d2b0a64c70323b09bcee721663d6698fb557066f20ddaec97bba6869c1c6c"
|
||||
"assets/images/logos/colored_logo_without_mustache.png": "d11cff9f38985885873bfdd2d84e61f8fab03803eada94d4caac1545ef3685f3"
|
||||
"assets/images/logos/colored_logo_only_mustache.png": "bad6220c11bdfb1dfe04e5173bd2ebedd3999689d4b3a68fc63dc520c96dd33b"
|
||||
l10n:
|
||||
"l10n.yml": "a57bc304cac4a2b0235593586f17f400a5165d67fc9aadeaa11893cfa36ee082"
|
||||
"lib/l10n/app_de.arb": "ecfbf13bd33be9d27a2b54bfd8fb61e46c1a1dce905869d3f30cd05b4aecf258"
|
||||
"lib/l10n/app_en.arb": "7c43928f67b5b735283da04e3741f1afa2e9d41cdeb2e91c740e77fc84e7f046"
|
||||
"lib/l10n/app_hu.arb": "696a1ea2e86be364e9c815e5f739d3a2f5f3da9c05066084d9d26defe5018e2c"
|
||||
isar:
|
||||
"lib/data/models/app_settings_model.dart": "5eb5af345f1347f104257f0999763650fe2673f9da1754bd12d3f756fe5c9723"
|
||||
"lib/data/models/generic_cache_model.dart": "79151d0467fb5d40c532eaaa08ad7c7e24a34304199280fbf49cf6e5adcce6bc"
|
||||
"lib/data/models/homework_cache_model.dart": "45789970b27d5790cdc54c292ef2f5feaa5f4e293b8a8862fd676d5eb3e25d29"
|
||||
"lib/data/models/timetable_cache_model.dart": "b972bf51e399f8d20d4f9ad660082d4cc4a9798df9ac9d6ec9ef6ac640205572"
|
||||
"lib/data/models/token_model.dart": "8c957cd07e473827d78fd8fd4fb6c1336b636a69c25c93618e1e7f94b7cf0683"
|
||||
splash:
|
||||
"flutter_native_splash.yaml": "0fd4a85d6f950d97298e99916927649940ffcfdadfc136ceee126fed0dbaa9f2"
|
||||
"assets/images/logos/splash.png": "88fbebc3d686cb9095bcce362029b69978b1b14270e465e91d962b1425db1152"
|
||||
@@ -1,16 +1,21 @@
|
||||
flutter_native_splash:
|
||||
color: "#7ca120"
|
||||
image: assets/images/logos/splash.png
|
||||
# Keep image centered instead of fill-scaled (stops icon looking zoomed/cropped)
|
||||
android_gravity: center
|
||||
|
||||
# Dark mode - same color as light mode for consistency
|
||||
color_dark: "#7ca120"
|
||||
image_dark: assets/images/logos/splash.png
|
||||
|
||||
# Android 12+ uses 960×960 image with logo in 640px circle (generated by codegen) to avoid cropping
|
||||
android_12:
|
||||
image: assets/images/logos/splash.png
|
||||
image: assets/images/logos/splash_android12.png
|
||||
color: "#7ca120"
|
||||
color_dark: "#7ca120"
|
||||
image_dark: assets/images/logos/splash.png
|
||||
image_dark: assets/images/logos/splash_android12.png
|
||||
icon_background_color: "#7ca120"
|
||||
icon_background_color_dark: "#7ca120"
|
||||
|
||||
ios: true
|
||||
web: false
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:firka/helpers/db/models/generic_cache_model.dart';
|
||||
import 'package:firka/helpers/db/models/timetable_cache_model.dart';
|
||||
import 'package:firka/helpers/db/models/token_model.dart';
|
||||
import 'package:firka/main.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'test_helpers.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await resetAppData();
|
||||
setApiUrls();
|
||||
|
||||
group('main', () {
|
||||
testWidgets('InitializationScreen -> HomeScreen', (tester) async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
|
||||
var isar = await Isar.open(
|
||||
[TokenModelSchema, GenericCacheModelSchema, TimetableCacheModelSchema],
|
||||
inspector: true,
|
||||
directory: dir.path,
|
||||
);
|
||||
isarInit = isar;
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.tokenModels.put(TokenModel());
|
||||
});
|
||||
|
||||
await tester.pumpWidget(InitializationScreen());
|
||||
|
||||
await waitUntil(Duration(minutes: 2), tester, () async {
|
||||
var ele = find.byKey(const Key('homeScreen'));
|
||||
return ele.allCandidates.isNotEmpty;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:firka/main.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
import 'test_helpers.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
await resetAppData();
|
||||
setApiUrls();
|
||||
|
||||
group('main', () {
|
||||
testWidgets('InitializationScreen -> LoginScreen', (tester) async {
|
||||
await tester.pumpWidget(InitializationScreen());
|
||||
|
||||
await waitUntil(Duration(minutes: 2), tester, () async {
|
||||
var ele = find.byKey(const Key('loginScreen'));
|
||||
return ele.allCandidates.isNotEmpty;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:firka/helpers/api/consts.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
Future<bool> isWear() async {
|
||||
const platform = MethodChannel('firka.app/main');
|
||||
|
||||
return await platform.invokeMethod("isWear");
|
||||
}
|
||||
|
||||
Future<bool> isPhone() async {
|
||||
return !(await isWear());
|
||||
}
|
||||
|
||||
Future<void> resetAppData() async {
|
||||
final isarDir = await getApplicationDocumentsDirectory();
|
||||
if (await isarDir.exists()) await isarDir.delete(recursive: true);
|
||||
}
|
||||
|
||||
void setApiUrls() {
|
||||
KretaEndpoints.kretaBase = "localhost:8060";
|
||||
KretaEndpoints.kretaIdp = "http://localhost:8060";
|
||||
KretaEndpoints.kretaLoginUrl =
|
||||
"${KretaEndpoints.kretaIdp}/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Fprompt%3Dlogin%26nonce%3DwylCrqT4oN6PPgQn2yQB0euKei9nJeZ6_ffJ-VpSKZU%26response_type%3Dcode%26code_challenge_method%3DS256%26scope%3Dopenid%2520email%2520offline_access%2520kreta-ellenorzo-webapi.public%2520kreta-eugyintezes-webapi.public%2520kreta-fileservice-webapi.public%2520kreta-mobile-global-webapi.public%2520kreta-dkt-webapi.public%2520kreta-ier-webapi.public%26code_challenge%3DHByZRRnPGb-Ko_wTI7ibIba1HQ6lor0ws4bcgReuYSQ%26redirect_uri%3Dhttps%253A%252F%252Fmobil.e-kreta.hu%252Fellenorzo-student%252Fprod%252Foauthredirect%26client_id%3Dkreta-ellenorzo-student-mobile-ios%26state%3Dkreta_student_mobile%26suppressed_prompt%3Dlogin";
|
||||
KretaEndpoints.tokenGrantUrl = "${KretaEndpoints.kretaIdp}/connect/token";
|
||||
}
|
||||
|
||||
Future<void> waitUntil(Duration timeout, WidgetTester tester,
|
||||
Future<bool> Function() callback) async {
|
||||
var now = DateTime.now();
|
||||
while (
|
||||
now.difference(DateTime.now()).inMilliseconds < timeout.inMilliseconds) {
|
||||
await tester.pump(Duration(milliseconds: 100));
|
||||
|
||||
if (await callback()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw Exception("waitUntil timed out");
|
||||
}
|
||||
@@ -8,18 +8,23 @@ struct CountdownRing: View {
|
||||
var lineWidth: CGFloat = 8
|
||||
var displayOffset: Int = 0 // Add to displayed minutes (e.g., +1)
|
||||
|
||||
private var clampedRemainingMinutes: Int {
|
||||
guard totalMinutes > 0 else { return 0 }
|
||||
return max(0, min(remainingMinutes, totalMinutes))
|
||||
}
|
||||
|
||||
var progress: Double {
|
||||
guard totalMinutes > 0 else { return 0 }
|
||||
return Double(totalMinutes - remainingMinutes) / Double(totalMinutes)
|
||||
return Double(totalMinutes - clampedRemainingMinutes) / Double(totalMinutes)
|
||||
}
|
||||
|
||||
var displayedMinutes: Int {
|
||||
remainingMinutes + displayOffset
|
||||
max(0, remainingMinutes + displayOffset)
|
||||
}
|
||||
|
||||
var ringColor: Color {
|
||||
if remainingMinutes < 5 { return .red }
|
||||
if remainingMinutes < 10 { return .yellow }
|
||||
if clampedRemainingMinutes < 5 { return .red }
|
||||
if clampedRemainingMinutes < 10 { return .yellow }
|
||||
return .green
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,25 @@ import SwiftUI
|
||||
struct FirkaCard<Content: View>: View {
|
||||
let content: Content
|
||||
var isHighlighted: Bool = false
|
||||
var backgroundColor: Color? = nil
|
||||
|
||||
init(isHighlighted: Bool = false, @ViewBuilder content: () -> Content) {
|
||||
init(
|
||||
isHighlighted: Bool = false,
|
||||
backgroundColor: Color? = nil,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.isHighlighted = isHighlighted
|
||||
self.backgroundColor = backgroundColor
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(12)
|
||||
.background(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12))
|
||||
.background(
|
||||
backgroundColor ??
|
||||
(isHighlighted ? Color.green.opacity(0.2) : Color(white: 0.12))
|
||||
)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.task {
|
||||
dataStore.reconcileSharedSessionState()
|
||||
WatchL10n.shared.reconcileFromSharedState()
|
||||
dataStore.checkTokenState()
|
||||
dataStore.loadFromCache()
|
||||
if dataStore.hasToken {
|
||||
@@ -54,6 +56,8 @@ struct ContentView: View {
|
||||
}
|
||||
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||
if newPhase == .active && oldPhase != .active {
|
||||
dataStore.reconcileSharedSessionState()
|
||||
WatchL10n.shared.reconcileFromSharedState()
|
||||
if shouldAutoRefresh {
|
||||
print("[Watch] App came to foreground, data is stale (>10 min), refreshing...")
|
||||
Task {
|
||||
@@ -65,7 +69,22 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onReceive(staleCheckTimer) { _ in
|
||||
if scenePhase == .active && shouldAutoRefresh && !dataStore.isLoading {
|
||||
guard scenePhase == .active else { return }
|
||||
dataStore.reconcileSharedSessionState()
|
||||
WatchL10n.shared.reconcileFromSharedState()
|
||||
|
||||
if !dataStore.hasToken {
|
||||
dataStore.checkTokenState()
|
||||
if dataStore.hasToken {
|
||||
print("[Watch] Token appeared (iCloud Keychain sync?), refreshing...")
|
||||
Task {
|
||||
await dataStore.refreshAllWithRecovery()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if shouldAutoRefresh && !dataStore.isLoading {
|
||||
print("[Watch] Data became stale (>10 min), auto-refreshing...")
|
||||
Task {
|
||||
await dataStore.refreshAllWithRecovery()
|
||||
@@ -124,22 +143,41 @@ struct ContentView: View {
|
||||
struct PairingView: View {
|
||||
var onRequestToken: (() -> Void)?
|
||||
|
||||
private var isWatchSystemPaired: Bool {
|
||||
guard WCSession.isSupported() else { return false }
|
||||
return WCSession.default.isCompanionAppInstalled
|
||||
}
|
||||
|
||||
private var titleKey: String {
|
||||
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
|
||||
}
|
||||
|
||||
private var descriptionKey: String {
|
||||
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
isWatchSystemPaired
|
||||
? "person.crop.circle.badge.exclamationmark"
|
||||
: "iphone.and.arrow.right.inward"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "iphone.and.arrow.right.inward")
|
||||
.font(.system(size: 50))
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text("pair_with_iphone".localized)
|
||||
Text(titleKey.localized)
|
||||
.font(.headline)
|
||||
|
||||
Text("open_firka_on_iphone".localized)
|
||||
.font(.caption)
|
||||
Text(descriptionKey.localized)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
if WCSession.default.isReachable {
|
||||
if isWatchSystemPaired {
|
||||
Button("sync_button".localized) {
|
||||
onRequestToken?()
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)app.firka.firka</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.firka.firkaa</string>
|
||||
<string>group.app.firka.firka</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)app.firka.shared</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -30,7 +30,8 @@ class WatchL10n {
|
||||
|
||||
private let languageKey = "watch_language"
|
||||
private let syncWithiPhoneKey = "watch_sync_language_with_iphone"
|
||||
private static let appGroupID = "group.app.firka.firkaa"
|
||||
private let lastAppliedSharedLanguageVersionKey = "watch_last_applied_shared_language_version"
|
||||
private static let appGroupID = "group.app.firka.firka"
|
||||
private var appGroupDefaults: UserDefaults? {
|
||||
UserDefaults(suiteName: Self.appGroupID)
|
||||
}
|
||||
@@ -45,8 +46,9 @@ class WatchL10n {
|
||||
var syncWithiPhone: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(syncWithiPhone, forKey: syncWithiPhoneKey)
|
||||
appGroupDefaults?.set(syncWithiPhone, forKey: syncWithiPhoneKey)
|
||||
if syncWithiPhone {
|
||||
requestLanguageFromiPhone()
|
||||
refreshFromiPhoneAndSharedState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +58,13 @@ class WatchL10n {
|
||||
private init() {
|
||||
let savedLanguage = UserDefaults.standard.string(forKey: languageKey) ?? "hu"
|
||||
self.currentLanguage = WatchLanguage(rawValue: savedLanguage) ?? .hungarian
|
||||
self.syncWithiPhone = UserDefaults.standard.bool(forKey: syncWithiPhoneKey)
|
||||
if let storedSyncPref = UserDefaults.standard.object(forKey: syncWithiPhoneKey) as? Bool {
|
||||
self.syncWithiPhone = storedSyncPref
|
||||
} else {
|
||||
self.syncWithiPhone = true
|
||||
UserDefaults.standard.set(true, forKey: syncWithiPhoneKey)
|
||||
appGroupDefaults?.set(true, forKey: syncWithiPhoneKey)
|
||||
}
|
||||
appGroupDefaults?.set(currentLanguage.rawValue, forKey: languageKey)
|
||||
loadStrings()
|
||||
}
|
||||
@@ -66,16 +74,78 @@ class WatchL10n {
|
||||
}
|
||||
|
||||
func setLanguage(_ language: WatchLanguage) {
|
||||
currentLanguage = language
|
||||
loadStrings()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
if Thread.isMainThread {
|
||||
currentLanguage = language
|
||||
loadStrings()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
} else {
|
||||
DispatchQueue.main.async { [self] in
|
||||
currentLanguage = language
|
||||
loadStrings()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateFromiPhone(languageCode: String) {
|
||||
func updateFromiPhone(languageCode: String, sharedStateVersion: Int64? = nil) {
|
||||
guard syncWithiPhone else { return }
|
||||
if let language = WatchLanguage(rawValue: languageCode) {
|
||||
setLanguage(language)
|
||||
let lastAppliedVersion = lastAppliedSharedLanguageVersion()
|
||||
if let sharedStateVersion,
|
||||
sharedStateVersion > 0,
|
||||
sharedStateVersion < lastAppliedVersion {
|
||||
print("[WatchL10n] Ignoring stale WC language update (version: \(sharedStateVersion), lastApplied: \(lastAppliedVersion))")
|
||||
return
|
||||
}
|
||||
|
||||
if let language = WatchLanguage(rawValue: languageCode) {
|
||||
if language != currentLanguage {
|
||||
setLanguage(language)
|
||||
}
|
||||
if let sharedStateVersion, sharedStateVersion > 0 {
|
||||
setLastAppliedSharedLanguageVersion(max(lastAppliedVersion, sharedStateVersion))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func parseInt64(_ value: Any?) -> Int64? {
|
||||
if let value = value as? Int64 { return value }
|
||||
if let value = value as? Int { return Int64(value) }
|
||||
if let value = value as? Double { return Int64(value) }
|
||||
if let value = value as? String, let parsed = Int64(value) { return parsed }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func lastAppliedSharedLanguageVersion() -> Int64 {
|
||||
parseInt64(UserDefaults.standard.object(forKey: lastAppliedSharedLanguageVersionKey)) ?? 0
|
||||
}
|
||||
|
||||
private func setLastAppliedSharedLanguageVersion(_ value: Int64) {
|
||||
UserDefaults.standard.set(value, forKey: lastAppliedSharedLanguageVersionKey)
|
||||
}
|
||||
|
||||
func resetLanguageVersionTracking() {
|
||||
setLastAppliedSharedLanguageVersion(0)
|
||||
print("[WatchL10n] Language version tracking reset for account switch")
|
||||
}
|
||||
|
||||
func reconcileFromSharedState() {
|
||||
guard syncWithiPhone else { return }
|
||||
guard let sharedState = SharedLanguageStateManager.shared.loadState() else { return }
|
||||
let lastAppliedVersion = lastAppliedSharedLanguageVersion()
|
||||
guard sharedState.stateVersion > lastAppliedVersion else { return }
|
||||
|
||||
if let language = WatchLanguage(rawValue: sharedState.languageCode) {
|
||||
if language != currentLanguage {
|
||||
setLanguage(language)
|
||||
}
|
||||
setLastAppliedSharedLanguageVersion(sharedState.stateVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshFromiPhoneAndSharedState() {
|
||||
guard syncWithiPhone else { return }
|
||||
requestLanguageFromiPhone()
|
||||
reconcileFromSharedState()
|
||||
}
|
||||
|
||||
private func requestLanguageFromiPhone() {
|
||||
@@ -113,12 +183,20 @@ class WatchL10n {
|
||||
"no_more_lessons": "Ma nincs több órád",
|
||||
"pair_with_iphone": "Párosítsd az iPhone-oddal",
|
||||
"open_firka_on_iphone": "Nyisd meg a Firka appot az iPhone-odon",
|
||||
"login_on_iphone": "Jelentkezz be iPhone-on",
|
||||
"open_and_login_on_iphone": "Nyisd meg a Firka appot iPhone-on, és lépj be egy fiókba",
|
||||
"updated": "Frissítve: %@",
|
||||
"minutes": "perc",
|
||||
"time_now": "most",
|
||||
"time_hours_minutes": "%d ó %d p",
|
||||
"time_hours": "%d óra",
|
||||
"time_minutes_only": "%d perc",
|
||||
"time_since_minutes_one": "1 perce",
|
||||
"time_since_minutes_many": "%d perce",
|
||||
"time_since_hours_one": "1 órája",
|
||||
"time_since_hours_many": "%d órája",
|
||||
"time_since_days_one": "1 napja",
|
||||
"time_since_days_many": "%d napja",
|
||||
|
||||
// Timetable View
|
||||
"free_day": "Szabad nap",
|
||||
@@ -198,12 +276,20 @@ class WatchL10n {
|
||||
"no_more_lessons": "No more lessons today",
|
||||
"pair_with_iphone": "Pair with iPhone",
|
||||
"open_firka_on_iphone": "Open Firka app on your iPhone",
|
||||
"login_on_iphone": "Sign in on iPhone",
|
||||
"open_and_login_on_iphone": "Open Firka on your iPhone and sign in to an account",
|
||||
"updated": "Updated: %@",
|
||||
"minutes": "min",
|
||||
"time_now": "now",
|
||||
"time_hours_minutes": "%dh %dm",
|
||||
"time_hours": "%d hours",
|
||||
"time_minutes_only": "%d min",
|
||||
"time_since_minutes_one": "1 min ago",
|
||||
"time_since_minutes_many": "%d mins ago",
|
||||
"time_since_hours_one": "1 hour ago",
|
||||
"time_since_hours_many": "%d hours ago",
|
||||
"time_since_days_one": "1 day ago",
|
||||
"time_since_days_many": "%d days ago",
|
||||
|
||||
// Timetable View
|
||||
"free_day": "Free Day",
|
||||
@@ -283,12 +369,20 @@ class WatchL10n {
|
||||
"no_more_lessons": "Keine Stunden mehr heute",
|
||||
"pair_with_iphone": "Mit iPhone koppeln",
|
||||
"open_firka_on_iphone": "Öffne Firka auf deinem iPhone",
|
||||
"login_on_iphone": "Auf iPhone anmelden",
|
||||
"open_and_login_on_iphone": "Öffne Firka auf deinem iPhone und melde dich mit einem Konto an",
|
||||
"updated": "Aktualisiert: %@",
|
||||
"minutes": "Min",
|
||||
"time_now": "jetzt",
|
||||
"time_hours_minutes": "%d Std %d Min",
|
||||
"time_hours": "%d Stunden",
|
||||
"time_minutes_only": "%d Min",
|
||||
"time_since_minutes_one": "vor 1 Min",
|
||||
"time_since_minutes_many": "vor %d Min",
|
||||
"time_since_hours_one": "vor 1 Std",
|
||||
"time_since_hours_many": "vor %d Std",
|
||||
"time_since_days_one": "vor 1 Tag",
|
||||
"time_since_days_many": "vor %d Tagen",
|
||||
|
||||
// Timetable View
|
||||
"free_day": "Freier Tag",
|
||||
|
||||
@@ -30,8 +30,10 @@ class DataStore {
|
||||
(error == "token_expired" || error == "no_token") && recoveryAttempted && !isRecoveringToken
|
||||
}
|
||||
|
||||
private let appGroupID = "group.app.firka.firkaa"
|
||||
private let appGroupID = "group.app.firka.firka"
|
||||
private let cacheFileName = "watch_data.json"
|
||||
private let lastHandledSessionStateVersionKey = "firka.watch.last_handled_session_state_version"
|
||||
private let lastHandledSessionActiveStudentIdNormKey = "firka.watch.last_handled_session_active_student_id_norm"
|
||||
|
||||
private init() {
|
||||
checkTokenState()
|
||||
@@ -48,6 +50,73 @@ class DataStore {
|
||||
print("[Watch] Token state updated: hasToken = \(hasToken)")
|
||||
}
|
||||
|
||||
private func parseInt64(_ value: Any?) -> Int64? {
|
||||
if let value = value as? Int64 { return value }
|
||||
if let value = value as? Int { return Int64(value) }
|
||||
if let value = value as? Double { return Int64(value) }
|
||||
if let value = value as? String, let parsed = Int64(value) { return parsed }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func lastHandledSessionStateVersion() -> Int64 {
|
||||
parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionStateVersionKey)) ?? 0
|
||||
}
|
||||
|
||||
private func setLastHandledSessionStateVersion(_ value: Int64) {
|
||||
UserDefaults.standard.set(value, forKey: lastHandledSessionStateVersionKey)
|
||||
}
|
||||
|
||||
private func lastHandledSessionActiveStudentIdNorm() -> Int64? {
|
||||
parseInt64(UserDefaults.standard.object(forKey: lastHandledSessionActiveStudentIdNormKey))
|
||||
}
|
||||
|
||||
private func setLastHandledSessionActiveStudentIdNorm(_ value: Int64?) {
|
||||
if let value {
|
||||
UserDefaults.standard.set(value, forKey: lastHandledSessionActiveStudentIdNormKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: lastHandledSessionActiveStudentIdNormKey)
|
||||
}
|
||||
}
|
||||
|
||||
func reconcileSharedSessionState() {
|
||||
guard let state = SharedSessionStateManager.shared.loadState() else {
|
||||
return
|
||||
}
|
||||
|
||||
let lastVersion = lastHandledSessionStateVersion()
|
||||
guard state.stateVersion > lastVersion else {
|
||||
return
|
||||
}
|
||||
|
||||
if !state.hasAnyAccount {
|
||||
print("[Watch] Shared session state: no active iPhone account, clearing watch state")
|
||||
clearAll()
|
||||
resetRecoveryState()
|
||||
setLastHandledSessionStateVersion(state.stateVersion)
|
||||
setLastHandledSessionActiveStudentIdNorm(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let activeStudentIdNorm = state.activeStudentIdNorm {
|
||||
let lastHandledActiveStudentIdNorm = lastHandledSessionActiveStudentIdNorm()
|
||||
if lastHandledActiveStudentIdNorm != activeStudentIdNorm {
|
||||
print("[Watch] Shared session switched active account to \(activeStudentIdNorm), clearing stale cache")
|
||||
clearCache()
|
||||
data = nil
|
||||
lastUpdated = nil
|
||||
error = nil
|
||||
recoveryAttempted = false
|
||||
WatchL10n.shared.resetLanguageVersionTracking()
|
||||
}
|
||||
setLastHandledSessionActiveStudentIdNorm(activeStudentIdNorm)
|
||||
} else {
|
||||
setLastHandledSessionActiveStudentIdNorm(nil)
|
||||
}
|
||||
|
||||
setLastHandledSessionStateVersion(state.stateVersion)
|
||||
checkTokenState()
|
||||
}
|
||||
|
||||
// MARK: - Cache Loading
|
||||
|
||||
func loadFromCache() {
|
||||
@@ -254,7 +323,31 @@ class DataStore {
|
||||
}
|
||||
}
|
||||
|
||||
private var isRecoveryInProgress: Bool = false
|
||||
|
||||
func refreshAllWithRecovery() async {
|
||||
guard !isRecoveryInProgress && !isLoading else {
|
||||
print("[Watch] refreshAllWithRecovery() already in progress or refreshAll() running, skipping duplicate call")
|
||||
return
|
||||
}
|
||||
isRecoveryInProgress = true
|
||||
defer { isRecoveryInProgress = false }
|
||||
|
||||
reconcileSharedSessionState()
|
||||
WatchL10n.shared.refreshFromiPhoneAndSharedState()
|
||||
|
||||
let sharedActiveStudentIdNorm = SharedSessionStateManager.shared.loadState()?.activeStudentIdNorm
|
||||
let localStudentIdNorm = TokenManager.shared.loadToken()?.studentIdNorm
|
||||
let shouldRequestTokenFromPhone =
|
||||
!hasValidToken ||
|
||||
(sharedActiveStudentIdNorm != nil && localStudentIdNorm != sharedActiveStudentIdNorm)
|
||||
|
||||
if shouldRequestTokenFromPhone {
|
||||
WatchConnectivityManager.shared.requestTokenFromPhone()
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
checkTokenState()
|
||||
}
|
||||
|
||||
await refreshAll()
|
||||
|
||||
guard error == "token_expired" || error == "no_token" else {
|
||||
@@ -422,24 +515,30 @@ class DataStore {
|
||||
let elapsed = Date().timeIntervalSince(lastUpdated)
|
||||
|
||||
if elapsed < 60 {
|
||||
return nil
|
||||
return "time_now".localized
|
||||
}
|
||||
|
||||
// Minutes
|
||||
let minutes = Int(elapsed / 60)
|
||||
if minutes < 60 {
|
||||
return minutes == 1 ? "1 perce" : "\(minutes) perce"
|
||||
return minutes == 1
|
||||
? "time_since_minutes_one".localized
|
||||
: "time_since_minutes_many".localized(minutes)
|
||||
}
|
||||
|
||||
// Hours
|
||||
let hours = Int(elapsed / 3600)
|
||||
if hours < 24 {
|
||||
return hours == 1 ? "1 órája" : "\(hours) órája"
|
||||
return hours == 1
|
||||
? "time_since_hours_one".localized
|
||||
: "time_since_hours_many".localized(hours)
|
||||
}
|
||||
|
||||
// Days
|
||||
let days = Int(elapsed / 86400)
|
||||
return days == 1 ? "1 napja" : "\(days) napja"
|
||||
return days == 1
|
||||
? "time_since_days_one".localized
|
||||
: "time_since_days_many".localized(days)
|
||||
}
|
||||
|
||||
/// Returns true if data is stale (> 1 hour old or never updated)
|
||||
|
||||
@@ -4,6 +4,8 @@ import WatchConnectivity
|
||||
class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
static let shared = WatchConnectivityManager()
|
||||
private let lastAppliedTokenUpdateKey = "watch_last_applied_token_update_ms"
|
||||
private let minPhoneTokenRequestInterval: TimeInterval = 5
|
||||
private var lastPhoneTokenRequestAt: Date?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
@@ -35,6 +37,22 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseInt64(_ value: Any?) -> Int64? {
|
||||
if let value = value as? Int64 {
|
||||
return value
|
||||
}
|
||||
if let value = value as? Int {
|
||||
return Int64(value)
|
||||
}
|
||||
if let value = value as? Double {
|
||||
return Int64(value)
|
||||
}
|
||||
if let value = value as? String, let parsed = Int64(value) {
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func activate() {
|
||||
print("[Watch] WatchConnectivityManager.activate() called")
|
||||
if WCSession.isSupported() {
|
||||
@@ -92,6 +110,17 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
) {
|
||||
print("[Watch] didReceiveMessage called: \(message)")
|
||||
|
||||
if let messageId = message["id"] as? String, messageId == "token_update" {
|
||||
if let authDict = message["auth"] as? [String: Any] {
|
||||
print("[Watch] Received immediate token_update via sendMessage")
|
||||
processAuthData(authDict)
|
||||
replyHandler(["success": true])
|
||||
} else {
|
||||
replyHandler(["error": "no_auth"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let action = message["action"] as? String else {
|
||||
replyHandler(["error": "no_action"])
|
||||
return
|
||||
@@ -179,6 +208,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
if let lastPhoneTokenRequestAt,
|
||||
now.timeIntervalSince(lastPhoneTokenRequestAt) < minPhoneTokenRequestInterval {
|
||||
print("[Watch] Skipping token request due to cooldown")
|
||||
return
|
||||
}
|
||||
lastPhoneTokenRequestAt = now
|
||||
|
||||
print("[Watch] Requesting token from iPhone...")
|
||||
|
||||
WCSession.default.sendMessage(
|
||||
@@ -201,14 +238,26 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
|
||||
private func processApplicationContext(_ context: [String: Any]) {
|
||||
if (context["force_logout"] as? Bool) == true {
|
||||
print("[Watch] Received force_logout via applicationContext")
|
||||
handleForceLogoutFromPhone()
|
||||
return
|
||||
}
|
||||
|
||||
if let authDict = context["auth"] as? [String: Any] {
|
||||
print("[Watch] Received auth from iPhone")
|
||||
processAuthData(authDict)
|
||||
}
|
||||
|
||||
if let language = context["language"] as? String {
|
||||
let sharedStateVersion =
|
||||
parseInt64(context["language_state_version"]) ??
|
||||
parseInt64(context["languageStateVersion"])
|
||||
print("[Watch] Received language from iPhone: \(language)")
|
||||
WatchL10n.shared.updateFromiPhone(languageCode: language)
|
||||
WatchL10n.shared.updateFromiPhone(
|
||||
languageCode: language,
|
||||
sharedStateVersion: sharedStateVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,18 +271,38 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
case "language_update":
|
||||
if let language = userInfo["language"] as? String {
|
||||
let sharedStateVersion =
|
||||
parseInt64(userInfo["language_state_version"]) ??
|
||||
parseInt64(userInfo["languageStateVersion"])
|
||||
print("[Watch] Received language_update via userInfo: \(language)")
|
||||
WatchL10n.shared.updateFromiPhone(languageCode: language)
|
||||
WatchL10n.shared.updateFromiPhone(
|
||||
languageCode: language,
|
||||
sharedStateVersion: sharedStateVersion
|
||||
)
|
||||
}
|
||||
case "reauth_required":
|
||||
print("[Watch] Received reauth_required notification from iPhone")
|
||||
DataStore.shared.setReauthRequired()
|
||||
case "force_logout":
|
||||
print("[Watch] Received force_logout notification from iPhone")
|
||||
handleForceLogoutFromPhone()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleForceLogoutFromPhone() {
|
||||
TokenManager.shared.deleteToken()
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: false,
|
||||
activeStudentIdNorm: nil
|
||||
)
|
||||
DataStore.shared.clearAll()
|
||||
DataStore.shared.resetRecoveryState()
|
||||
DataStore.shared.checkTokenState()
|
||||
}
|
||||
|
||||
func sendTokenToiPhoneInBackground() {
|
||||
guard WCSession.default.activationState == .activated else {
|
||||
print("[Watch] Cannot send token: session not activated")
|
||||
@@ -296,8 +365,14 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
print("[Watch] Received language response from iPhone")
|
||||
DispatchQueue.main.async {
|
||||
if let language = response["language"] as? String {
|
||||
let sharedStateVersion =
|
||||
self.parseInt64(response["language_state_version"]) ??
|
||||
self.parseInt64(response["languageStateVersion"])
|
||||
print("[Watch] Language received from iPhone: \(language)")
|
||||
WatchL10n.shared.updateFromiPhone(languageCode: language)
|
||||
WatchL10n.shared.updateFromiPhone(
|
||||
languageCode: language,
|
||||
sharedStateVersion: sharedStateVersion
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -329,19 +404,24 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
|
||||
let token = try decoder.decode(WatchToken.self, from: jsonData)
|
||||
let currentToken = TokenManager.shared.loadToken()
|
||||
|
||||
let isAccountSwitch = currentToken != nil && !token.isSameAccount(as: currentToken!)
|
||||
let shouldForceAccountSwitch: Bool
|
||||
if incomingSentAtMs > 0,
|
||||
let currentToken,
|
||||
!token.isSameAccount(as: currentToken) {
|
||||
shouldForceAccountSwitch = true
|
||||
if isAccountSwitch {
|
||||
if incomingSentAtMs > 0 {
|
||||
shouldForceAccountSwitch = true
|
||||
} else {
|
||||
shouldForceAccountSwitch = token.isNewer(than: currentToken!)
|
||||
}
|
||||
} else {
|
||||
shouldForceAccountSwitch = false
|
||||
}
|
||||
|
||||
if incomingSentAtMs <= 0,
|
||||
let currentToken,
|
||||
!isAccountSwitch,
|
||||
!token.isNewer(than: currentToken) {
|
||||
print("[Watch] Ignoring stale token_update without sentAtMs")
|
||||
print("[Watch] Ignoring stale token_update without sentAtMs (same account, not newer)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -349,14 +429,20 @@ class WatchConnectivityManager: NSObject, WCSessionDelegate {
|
||||
|
||||
try TokenManager.shared.saveToken(
|
||||
token,
|
||||
syncToICloud: false,
|
||||
syncToSharedKeychain: false,
|
||||
forceAccountSwitch: shouldForceAccountSwitch
|
||||
)
|
||||
print("[Watch] Token saved successfully")
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: true,
|
||||
activeStudentIdNorm: token.studentIdNorm
|
||||
)
|
||||
if incomingSentAtMs > 0 {
|
||||
lastAppliedTokenUpdateMs = max(previousSentAtMs, incomingSentAtMs)
|
||||
}
|
||||
|
||||
DataStore.shared.clearError()
|
||||
DataStore.shared.resetRecoveryState()
|
||||
DataStore.shared.checkTokenState()
|
||||
|
||||
Task {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import WatchConnectivity
|
||||
internal import Combine
|
||||
|
||||
struct HomeView: View {
|
||||
@@ -92,10 +93,10 @@ struct HomeView: View {
|
||||
.disabled(dataStore.isLoading || refreshStatus == .loading)
|
||||
.padding(.top, 8)
|
||||
.onChange(of: dataStore.isLoading) { oldValue, newValue in
|
||||
if newValue && refreshStatus == .idle {
|
||||
if newValue && refreshStatus != .loading {
|
||||
wasLoadingFromBackground = true
|
||||
}
|
||||
if !newValue && wasLoadingFromBackground && refreshStatus == .idle {
|
||||
if !newValue && wasLoadingFromBackground && refreshStatus != .loading {
|
||||
wasLoadingFromBackground = false
|
||||
if dataStore.error == nil && dataStore.data != nil {
|
||||
refreshStatus = .success
|
||||
@@ -110,6 +111,20 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: dataStore.lastUpdated) { oldValue, newValue in
|
||||
guard let oldValue, let newValue else { return }
|
||||
guard newValue > oldValue else { return }
|
||||
guard dataStore.error == nil else { return }
|
||||
guard refreshStatus != .loading else { return }
|
||||
|
||||
refreshStatus = .success
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if refreshStatus == .success {
|
||||
refreshStatus = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var refreshStatusText: String {
|
||||
@@ -195,12 +210,20 @@ struct HomeView: View {
|
||||
displayOffset: 1
|
||||
)
|
||||
.id("lesson-\(lesson.start.timeIntervalSince1970)")
|
||||
FirkaCard(isHighlighted: true) {
|
||||
FirkaCard(
|
||||
isHighlighted: true,
|
||||
backgroundColor: lessonCardBackgroundColor(
|
||||
for: lesson,
|
||||
isHighlighted: true
|
||||
)
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(lesson.displayName)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
lessonTitleWithStatus(
|
||||
lesson,
|
||||
font: .subheadline,
|
||||
weight: .semibold,
|
||||
lineLimit: 2
|
||||
)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
if let room = lesson.roomName {
|
||||
@@ -221,11 +244,15 @@ struct HomeView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
FirkaCard {
|
||||
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(next.displayName)
|
||||
.font(.subheadline)
|
||||
lessonTitleWithStatus(
|
||||
next,
|
||||
font: .subheadline,
|
||||
weight: .regular,
|
||||
lineLimit: 2
|
||||
)
|
||||
if let room = next.roomName {
|
||||
Text(room)
|
||||
.font(.caption2)
|
||||
@@ -251,11 +278,16 @@ struct HomeView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
let remaining = max(0, Int(next.start.timeIntervalSince(now) / 60))
|
||||
let remaining = max(0, Int(ceil(next.start.timeIntervalSince(now) / 60)))
|
||||
let totalBreakMinutes: Int = {
|
||||
guard let previous = previousLesson else { return max(remaining, 1) }
|
||||
let breakSeconds = max(60, next.start.timeIntervalSince(previous.end))
|
||||
return max(1, Int(ceil(breakSeconds / 60)))
|
||||
}()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
CountdownRing(
|
||||
totalMinutes: 15,
|
||||
totalMinutes: totalBreakMinutes,
|
||||
remainingMinutes: remaining,
|
||||
label: "minutes".localized,
|
||||
size: 56,
|
||||
@@ -264,12 +296,14 @@ struct HomeView: View {
|
||||
)
|
||||
.id("break-\(next.start.timeIntervalSince1970)")
|
||||
|
||||
FirkaCard {
|
||||
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: next)) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("next_lesson".localized(next.displayName))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
HStack(spacing: 4) {
|
||||
Text("next_lesson".localized(next.displayName))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
if let room = next.roomName {
|
||||
@@ -295,10 +329,14 @@ struct HomeView: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
FirkaCard {
|
||||
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: first)) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(first.displayName)
|
||||
.font(.headline)
|
||||
lessonTitleWithStatus(
|
||||
first,
|
||||
font: .headline,
|
||||
weight: .regular,
|
||||
lineLimit: 2
|
||||
)
|
||||
|
||||
HStack {
|
||||
if let room = first.roomName {
|
||||
@@ -341,10 +379,14 @@ struct HomeView: View {
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.top, 8)
|
||||
|
||||
FirkaCard {
|
||||
FirkaCard(backgroundColor: lessonCardBackgroundColor(for: nextLesson)) {
|
||||
HStack {
|
||||
Text(nextLesson.displayName)
|
||||
.font(.subheadline)
|
||||
lessonTitleWithStatus(
|
||||
nextLesson,
|
||||
font: .subheadline,
|
||||
weight: .regular,
|
||||
lineLimit: 2
|
||||
)
|
||||
Spacer()
|
||||
Text(nextLesson.start, style: .time)
|
||||
.font(.caption)
|
||||
@@ -419,6 +461,46 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lessonTitleWithStatus(
|
||||
_ lesson: WidgetLesson,
|
||||
font: Font,
|
||||
weight: Font.Weight = .regular,
|
||||
lineLimit: Int = 2
|
||||
) -> some View {
|
||||
Text(lesson.displayName)
|
||||
.font(font)
|
||||
.fontWeight(weight)
|
||||
.lineLimit(lineLimit)
|
||||
.foregroundColor(lessonPrimaryTextColor(for: lesson))
|
||||
}
|
||||
|
||||
private func lessonPrimaryTextColor(for lesson: WidgetLesson) -> Color {
|
||||
if lesson.isCancelled {
|
||||
return .red
|
||||
}
|
||||
if lesson.isSubstitution {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func lessonCardBackgroundColor(
|
||||
for lesson: WidgetLesson,
|
||||
isHighlighted: Bool = false
|
||||
) -> Color {
|
||||
if lesson.isCancelled {
|
||||
return Color.red.opacity(0.16)
|
||||
}
|
||||
if lesson.isSubstitution {
|
||||
return Color.yellow.opacity(0.16)
|
||||
}
|
||||
if isHighlighted {
|
||||
return Color.green.opacity(0.2)
|
||||
}
|
||||
return Color(white: 0.12)
|
||||
}
|
||||
|
||||
// MARK: - Break/Vacation View
|
||||
|
||||
@ViewBuilder
|
||||
@@ -438,17 +520,36 @@ struct HomeView: View {
|
||||
|
||||
// MARK: - No Token View
|
||||
|
||||
private var isWatchSystemPaired: Bool {
|
||||
guard WCSession.isSupported() else { return false }
|
||||
return WCSession.default.isCompanionAppInstalled
|
||||
}
|
||||
|
||||
private var noTokenTitleKey: String {
|
||||
isWatchSystemPaired ? "login_on_iphone" : "pair_with_iphone"
|
||||
}
|
||||
|
||||
private var noTokenDescriptionKey: String {
|
||||
isWatchSystemPaired ? "open_and_login_on_iphone" : "open_firka_on_iphone"
|
||||
}
|
||||
|
||||
private var noTokenIconName: String {
|
||||
isWatchSystemPaired
|
||||
? "person.crop.circle.badge.exclamationmark"
|
||||
: "iphone.and.arrow.right.inward"
|
||||
}
|
||||
|
||||
private var noTokenView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "iphone.and.arrow.right.inward")
|
||||
Image(systemName: noTokenIconName)
|
||||
.font(.system(size: 44))
|
||||
.foregroundColor(.blue)
|
||||
|
||||
Text("pair_with_iphone".localized)
|
||||
Text(noTokenTitleKey.localized)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("open_firka_on_iphone".localized)
|
||||
Text(noTokenDescriptionKey.localized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -289,7 +289,7 @@ struct ReauthRequiredView: View {
|
||||
|
||||
try TokenManager.shared.saveToken(
|
||||
token,
|
||||
syncToICloud: false,
|
||||
syncToSharedKeychain: false,
|
||||
forceAccountSwitch: shouldForceAccountSwitch
|
||||
)
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ struct SettingsView: View {
|
||||
|
||||
private func logout() {
|
||||
TokenManager.shared.deleteToken()
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: false,
|
||||
activeStudentIdNorm: nil
|
||||
)
|
||||
DataStore.shared.clearAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,17 +308,17 @@ struct TimetableView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(lesson.displayName)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
.strikethrough(lesson.isCancelled)
|
||||
.opacity(lesson.isCancelled ? 0.5 : 1)
|
||||
HStack(spacing: 4) {
|
||||
Text(lesson.displayName)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
|
||||
if lesson.isSubstitution {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.orange)
|
||||
if let statusIcon = lessonStatusIconName(for: lesson) {
|
||||
Image(systemName: statusIcon)
|
||||
.font(.caption2)
|
||||
.foregroundColor(lessonStatusColor(for: lesson))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -340,11 +340,23 @@ struct TimetableView: View {
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(lesson.isCancelled ? 0.5 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(lesson.isCancelled ? 0.6 : 1)
|
||||
}
|
||||
|
||||
private func lessonStatusIconName(for lesson: WidgetLesson) -> String? {
|
||||
if lesson.isCancelled {
|
||||
return "xmark.circle.fill"
|
||||
}
|
||||
if lesson.isSubstitution {
|
||||
return "exclamationmark.circle.fill"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func lessonStatusColor(for lesson: WidgetLesson) -> Color {
|
||||
lesson.isCancelled ? .red : .yellow
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import SwiftUI
|
||||
// MARK: - Complication Localization Helper
|
||||
|
||||
private struct ComplicationL10n {
|
||||
private static let appGroupID = "group.app.firka.firkaa"
|
||||
private static let appGroupID = "group.app.firka.firka"
|
||||
|
||||
enum Language: String {
|
||||
case hungarian = "hu"
|
||||
@@ -63,7 +63,7 @@ private struct ComplicationL10n {
|
||||
// MARK: - Watch Cache Loader
|
||||
|
||||
private struct WatchCacheLoader {
|
||||
private static let appGroupID = "group.app.firka.firkaa"
|
||||
private static let appGroupID = "group.app.firka.firka"
|
||||
private static let cacheFileName = "watch_data.json"
|
||||
|
||||
static func loadWidgetData() -> WidgetData? {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)app.firka.firka</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.firka.firkaa</string>
|
||||
<string>group.app.firka.firka</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.firka.firkaa</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.firka.firka</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,7 +2,7 @@ import WidgetKit
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
private let appGroup = "group.app.firka.firkaa"
|
||||
private let appGroup = "group.app.firka.firka"
|
||||
|
||||
// MARK: - Navigation Intents (iOS 16+, used by Controls and Shortcuts)
|
||||
|
||||
@@ -46,7 +46,7 @@ struct OpenTimetableIntent: AppIntent {
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct HomeControl: ControlWidget {
|
||||
static let kind = "app.firka.firkaa.control.home"
|
||||
static let kind = "app.firka.firka.control.home"
|
||||
|
||||
var body: some ControlWidgetConfiguration {
|
||||
StaticControlConfiguration(kind: Self.kind) {
|
||||
@@ -63,7 +63,7 @@ struct HomeControl: ControlWidget {
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct GradesControl: ControlWidget {
|
||||
static let kind = "app.firka.firkaa.control.grades"
|
||||
static let kind = "app.firka.firka.control.grades"
|
||||
|
||||
var body: some ControlWidgetConfiguration {
|
||||
StaticControlConfiguration(kind: Self.kind) {
|
||||
@@ -80,7 +80,7 @@ struct GradesControl: ControlWidget {
|
||||
|
||||
@available(iOS 18.0, *)
|
||||
struct TimetableControl: ControlWidget {
|
||||
static let kind = "app.firka.firkaa.control.timetable"
|
||||
static let kind = "app.firka.firka.control.timetable"
|
||||
|
||||
var body: some ControlWidgetConfiguration {
|
||||
StaticControlConfiguration(kind: Self.kind) {
|
||||
|
||||
@@ -28,6 +28,11 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
||||
typealias Entry = TimetableEntry
|
||||
typealias Intent = TimetableWidgetIntent
|
||||
|
||||
private struct LessonCandidate {
|
||||
let lessons: [WidgetLesson]
|
||||
let day: Date
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
@@ -36,9 +41,49 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let isoFormatterWithFractional: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func parseNextSchoolDayDate(_ dateString: String?) -> Date? {
|
||||
guard let dateString = dateString else { return nil }
|
||||
return Self.dateFormatter.date(from: dateString)
|
||||
if let date = Self.isoFormatterWithFractional.date(from: dateString) {
|
||||
return date
|
||||
}
|
||||
if let date = Self.isoFormatter.date(from: dateString) {
|
||||
return date
|
||||
}
|
||||
if let date = Self.dateFormatter.date(from: dateString) {
|
||||
return date
|
||||
}
|
||||
|
||||
let trimmed = String(dateString.prefix(10))
|
||||
return Self.dateFormatter.date(from: trimmed)
|
||||
}
|
||||
|
||||
private func startOfDay(for lessons: [WidgetLesson], calendar: Calendar) -> Date? {
|
||||
guard let first = lessons.first else { return nil }
|
||||
return calendar.startOfDay(for: first.start)
|
||||
}
|
||||
|
||||
private func nextSchoolDay(from data: WidgetData, calendar: Calendar) -> Date? {
|
||||
if let firstNextLesson = data.timetable.nextSchoolDay?.first {
|
||||
return calendar.startOfDay(for: firstNextLesson.start)
|
||||
}
|
||||
|
||||
if let parsedDate = parseNextSchoolDayDate(data.timetable.nextSchoolDayDate) {
|
||||
return calendar.startOfDay(for: parsedDate)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func placeholder(in context: Context) -> TimetableEntry {
|
||||
@@ -176,9 +221,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
||||
let midnight = calendar.startOfDay(for: now.addingTimeInterval(86400))
|
||||
entries.append(createEntry(for: configuration, date: midnight))
|
||||
|
||||
if let nextSchoolDayDateString = data?.timetable.nextSchoolDayDate,
|
||||
let nextSchoolDayDate = parseNextSchoolDayDate(nextSchoolDayDateString) {
|
||||
let nextSchoolDay = calendar.startOfDay(for: nextSchoolDayDate)
|
||||
if let data = data,
|
||||
let nextSchoolDay = nextSchoolDay(from: data, calendar: calendar) {
|
||||
let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)!
|
||||
|
||||
if dayBeforeNextSchoolDay > now {
|
||||
@@ -250,93 +294,47 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
||||
}
|
||||
|
||||
let entryDay = calendar.startOfDay(for: date)
|
||||
let tomorrowOfEntryDay = calendar.date(byAdding: .day, value: 1, to: entryDay)!
|
||||
|
||||
var lessons = data.timetable.today
|
||||
var isNextDay = false
|
||||
let todayLessons = data.timetable.today
|
||||
let tomorrowLessons = data.timetable.tomorrow
|
||||
let nextSchoolDayLessons = data.timetable.nextSchoolDay ?? []
|
||||
|
||||
if let firstTodayLesson = lessons.first {
|
||||
let todayLessonDay = calendar.startOfDay(for: firstTodayLesson.start)
|
||||
var candidates: [LessonCandidate] = []
|
||||
|
||||
if entryDay > todayLessonDay {
|
||||
lessons = data.timetable.tomorrow
|
||||
if let firstTomorrowLesson = lessons.first {
|
||||
let tomorrowLessonDay = calendar.startOfDay(for: firstTomorrowLesson.start)
|
||||
isNextDay = entryDay < tomorrowLessonDay
|
||||
}
|
||||
} else {
|
||||
let lastLesson = lessons.last
|
||||
if let last = lastLesson, date > last.end {
|
||||
lessons = data.timetable.tomorrow
|
||||
isNextDay = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lessons = data.timetable.tomorrow
|
||||
if !lessons.isEmpty {
|
||||
isNextDay = true
|
||||
}
|
||||
if let todayDay = startOfDay(for: todayLessons, calendar: calendar), !todayLessons.isEmpty {
|
||||
candidates.append(LessonCandidate(lessons: todayLessons, day: todayDay))
|
||||
}
|
||||
|
||||
if lessons.isEmpty {
|
||||
if let nextSchoolDayLessons = data.timetable.nextSchoolDay, !nextSchoolDayLessons.isEmpty {
|
||||
if let nextSchoolDayDate = parseNextSchoolDayDate(data.timetable.nextSchoolDayDate) {
|
||||
let nextSchoolDay = calendar.startOfDay(for: nextSchoolDayDate)
|
||||
let dayBeforeNextSchoolDay = calendar.date(byAdding: .day, value: -1, to: nextSchoolDay)!
|
||||
if let tomorrowDay = startOfDay(for: tomorrowLessons, calendar: calendar), !tomorrowLessons.isEmpty {
|
||||
candidates.append(LessonCandidate(lessons: tomorrowLessons, day: tomorrowDay))
|
||||
}
|
||||
|
||||
if entryDay == nextSchoolDay {
|
||||
let currentLesson = nextSchoolDayLessons.first { lesson in
|
||||
return date >= lesson.start && date <= lesson.end
|
||||
}
|
||||
let nextLesson = nextSchoolDayLessons.first { $0.start > date }
|
||||
if !nextSchoolDayLessons.isEmpty,
|
||||
let resolvedNextSchoolDay = nextSchoolDay(from: data, calendar: calendar)
|
||||
?? startOfDay(for: nextSchoolDayLessons, calendar: calendar) {
|
||||
candidates.append(LessonCandidate(lessons: nextSchoolDayLessons, day: resolvedNextSchoolDay))
|
||||
}
|
||||
|
||||
return TimetableEntry(
|
||||
date: date,
|
||||
configuration: configuration,
|
||||
data: data,
|
||||
lessons: nextSchoolDayLessons,
|
||||
currentLesson: currentLesson,
|
||||
nextLesson: nextLesson,
|
||||
isNextDay: false,
|
||||
isNextSchoolDay: false,
|
||||
nextSchoolDayDateString: nil,
|
||||
breakInfo: nil,
|
||||
state: .normal,
|
||||
debugInfo: WidgetData.lastError
|
||||
)
|
||||
}
|
||||
candidates.sort { $0.day < $1.day }
|
||||
|
||||
if entryDay == dayBeforeNextSchoolDay {
|
||||
return TimetableEntry(
|
||||
date: date,
|
||||
configuration: configuration,
|
||||
data: data,
|
||||
lessons: nextSchoolDayLessons,
|
||||
currentLesson: nil,
|
||||
nextLesson: nextSchoolDayLessons.first,
|
||||
isNextDay: true,
|
||||
isNextSchoolDay: false,
|
||||
nextSchoolDayDateString: nil,
|
||||
breakInfo: nil,
|
||||
state: .normal,
|
||||
debugInfo: WidgetData.lastError
|
||||
)
|
||||
}
|
||||
}
|
||||
// Pick the closest candidate that still has lessons ahead relative to this entry date.
|
||||
let selectedCandidate = candidates.first { candidate in
|
||||
if candidate.day > entryDay {
|
||||
return true
|
||||
}
|
||||
|
||||
return TimetableEntry(
|
||||
date: date,
|
||||
configuration: configuration,
|
||||
data: data,
|
||||
lessons: nextSchoolDayLessons,
|
||||
currentLesson: nil,
|
||||
nextLesson: nextSchoolDayLessons.first,
|
||||
isNextDay: false,
|
||||
isNextSchoolDay: true,
|
||||
nextSchoolDayDateString: data.timetable.nextSchoolDayDate,
|
||||
breakInfo: nil,
|
||||
state: .normal,
|
||||
debugInfo: WidgetData.lastError
|
||||
)
|
||||
if candidate.day == entryDay, let lastLesson = candidate.lessons.last {
|
||||
return date <= lastLesson.end
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
guard let selectedCandidate = selectedCandidate else {
|
||||
let hadLessonsTodayButFinished = candidates.contains { candidate in
|
||||
guard candidate.day == entryDay, let lastLesson = candidate.lessons.last else { return false }
|
||||
return date > lastLesson.end
|
||||
}
|
||||
|
||||
return TimetableEntry(
|
||||
@@ -346,15 +344,23 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
||||
lessons: [],
|
||||
currentLesson: nil,
|
||||
nextLesson: nil,
|
||||
isNextDay: isNextDay,
|
||||
isNextDay: false,
|
||||
isNextSchoolDay: false,
|
||||
nextSchoolDayDateString: nil,
|
||||
breakInfo: nil,
|
||||
state: isNextDay ? .noMoreLessons : .unavailable,
|
||||
state: hadLessonsTodayButFinished ? .noMoreLessons : .unavailable,
|
||||
debugInfo: WidgetData.lastError
|
||||
)
|
||||
}
|
||||
|
||||
let lessons = selectedCandidate.lessons
|
||||
let isToday = selectedCandidate.day == entryDay
|
||||
let isNextDay = selectedCandidate.day == tomorrowOfEntryDay
|
||||
let isNextSchoolDay = !isToday && !isNextDay
|
||||
let nextSchoolDayDateString = isNextSchoolDay
|
||||
? (data.timetable.nextSchoolDayDate ?? Self.dateFormatter.string(from: selectedCandidate.day))
|
||||
: nil
|
||||
|
||||
let currentLesson = lessons.first { lesson in
|
||||
return date >= lesson.start && date <= lesson.end
|
||||
}
|
||||
@@ -368,8 +374,8 @@ struct TimetableProvider: AppIntentTimelineProvider {
|
||||
currentLesson: currentLesson,
|
||||
nextLesson: nextLesson,
|
||||
isNextDay: isNextDay,
|
||||
isNextSchoolDay: false,
|
||||
nextSchoolDayDateString: nil,
|
||||
isNextSchoolDay: isNextSchoolDay,
|
||||
nextSchoolDayDateString: nextSchoolDayDateString,
|
||||
breakInfo: nil,
|
||||
state: .normal,
|
||||
debugInfo: WidgetData.lastError
|
||||
|
||||
@@ -117,8 +117,8 @@ struct TimetableMediumView: View {
|
||||
|
||||
var hasActiveBreak: Bool {
|
||||
let checkDate = entry.date
|
||||
for i in 0..<entry.lessons.count - 1 {
|
||||
if checkDate > entry.lessons[i].end && checkDate < entry.lessons[i + 1].start {
|
||||
for (currentLesson, nextLesson) in zip(entry.lessons, entry.lessons.dropFirst()) {
|
||||
if checkDate > currentLesson.end && checkDate < nextLesson.start {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -209,8 +209,8 @@ struct TimetableLargeView: View {
|
||||
|
||||
var hasActiveBreak: Bool {
|
||||
let checkDate = entry.date
|
||||
for i in 0..<entry.lessons.count - 1 {
|
||||
if checkDate > entry.lessons[i].end && checkDate < entry.lessons[i + 1].start {
|
||||
for (currentLesson, nextLesson) in zip(entry.lessons, entry.lessons.dropFirst()) {
|
||||
if checkDate > currentLesson.end && checkDate < nextLesson.start {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.firka.firkaa</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.firka.firka</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
213F8C0F6B5418B02DE14204 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 035E9CCBCC6585D0F5639031 /* Pods_Runner.framework */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
4F27D4D22F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */; };
|
||||
4F27D4D32F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */; };
|
||||
4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */; };
|
||||
4F30C7672E8FBF9D008BB46C /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */; };
|
||||
4F30C7692E8FBF9D008BB46C /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */; };
|
||||
4F30C7782E8FBF9F008BB46C /* LiveActivityWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F45A1732F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift */; };
|
||||
4F5824802F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */; };
|
||||
4F5824812F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */; };
|
||||
4F5824832F3548B800B92EA7 /* WatchToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5824822F3548B800B92EA7 /* WatchToken.swift */; };
|
||||
4F5824842F3548B800B92EA7 /* WatchToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5824822F3548B800B92EA7 /* WatchToken.swift */; };
|
||||
4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */; };
|
||||
@@ -177,12 +177,12 @@
|
||||
4836947EC3B04B475B3DA1F8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
485F3791F25A288C749509B2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
4F25FCBD2EB1790E0060DAAA /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedKeychainManager.swift; sourceTree = "<group>"; };
|
||||
4F30C7582E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityMethodChannelManager.swift; sourceTree = "<group>"; };
|
||||
4F30C7652E8FBF9D008BB46C /* LiveActivityWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LiveActivityWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
4F30C7662E8FBF9D008BB46C /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
4F30C7682E8FBF9D008BB46C /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
4F45A1732F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeWidgetMethodChannel.swift; sourceTree = "<group>"; };
|
||||
4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iCloudTokenManager.swift; sourceTree = "<group>"; };
|
||||
4F5824822F3548B800B92EA7 /* WatchToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchToken.swift; sourceTree = "<group>"; };
|
||||
4F5965F72F2F0C1600A3DB03 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = "<group>"; };
|
||||
4F5965FD2F2F0EAF00A3DB03 /* FirkaWatchComplicationsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FirkaWatchComplicationsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -387,10 +387,10 @@
|
||||
4F7701CD2F2EC1AA00B79171 /* API */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4F27D4D12F3F2F7700749B9A /* SharedKeychainManager.swift */,
|
||||
4F7701ED2F2EC2F500B79171 /* KretaAPIClient.swift */,
|
||||
4F7701EE2F2EC2F500B79171 /* TokenManager.swift */,
|
||||
4FCB030C2F330F3B00418E63 /* KretaAPIModels.swift */,
|
||||
4F58247F2F35468D00B92EA7 /* iCloudTokenManager.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
@@ -816,7 +816,7 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
|
||||
shellScript = "set -e\nNATIVE_ASSETS_DIR=\"$FLUTTER_APPLICATION_PATH/$FLUTTER_BUILD_DIR/native_assets\"\ncase \"$FLUTTER_BUILD_DIR\" in\n /*) NATIVE_ASSETS_DIR=\"$FLUTTER_BUILD_DIR/native_assets\" ;;\nesac\nif [ -d \"$FLUTTER_APPLICATION_PATH/.dart_tool/hooks_runner\" ]; then\n find \"$FLUTTER_APPLICATION_PATH/.dart_tool/hooks_runner\" -exec xattr -c {} \\; 2>/dev/null || true\nfi\nif [ -d \"$NATIVE_ASSETS_DIR\" ]; then\n find \"$NATIVE_ASSETS_DIR\" -exec xattr -c {} \\; 2>/dev/null || true\nfi\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
|
||||
};
|
||||
D576F90540C8E625A9A12317 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
@@ -916,12 +916,12 @@
|
||||
files = (
|
||||
4F7701E62F2EC1AA00B79171 /* Grade.swift in Sources */,
|
||||
4F7701E72F2EC1AA00B79171 /* WidgetColors.swift in Sources */,
|
||||
4F27D4D22F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */,
|
||||
4F7701E82F2EC1AA00B79171 /* Average.swift in Sources */,
|
||||
4F5824842F3548B800B92EA7 /* WatchToken.swift in Sources */,
|
||||
4FCB030D2F330F3B00418E63 /* KretaAPIModels.swift in Sources */,
|
||||
4F7701E92F2EC1AA00B79171 /* SeasonalIconHelper.swift in Sources */,
|
||||
4F7701EA2F2EC1AA00B79171 /* Lesson.swift in Sources */,
|
||||
4F5824802F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */,
|
||||
4F7701EB2F2EC1AA00B79171 /* Subject.swift in Sources */,
|
||||
4F7701EC2F2EC1AA00B79171 /* WidgetData.swift in Sources */,
|
||||
4F7701EF2F2EC2F500B79171 /* TokenManager.swift in Sources */,
|
||||
@@ -934,12 +934,12 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
4F27D4D32F3F2F7700749B9A /* SharedKeychainManager.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
4F30C7592E8FBF26008BB46C /* LiveActivityMethodChannelManager.swift in Sources */,
|
||||
4F45A1752F27FA3C0020E6F1 /* HomeWidgetMethodChannel.swift in Sources */,
|
||||
4F5965F82F2F0C1600A3DB03 /* WatchSessionManager.swift in Sources */,
|
||||
4F5824832F3548B800B92EA7 /* WatchToken.swift in Sources */,
|
||||
4F5824812F35468E00B92EA7 /* iCloudTokenManager.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -1076,18 +1076,18 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Firka;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -1108,7 +1108,8 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
|
||||
@@ -1127,7 +1128,8 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
|
||||
@@ -1144,7 +1146,8 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests;
|
||||
@@ -1159,9 +1162,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1171,8 +1174,8 @@
|
||||
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1190,7 +1193,7 @@
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.LiveActivityWidget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.LiveActivityWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1210,9 +1213,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1222,8 +1225,8 @@
|
||||
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1240,7 +1243,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.LiveActivityWidget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.LiveActivityWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1258,9 +1261,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1270,8 +1273,8 @@
|
||||
CODE_SIGN_ENTITLEMENTS = LiveActivityWidget.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1288,7 +1291,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.LiveActivityWidget;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.LiveActivityWidget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1306,9 +1309,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = "$(ARCHS_STANDARD)";
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1317,8 +1320,8 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1338,7 +1341,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp.complications;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.watchkitapp.complications;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1359,9 +1362,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = "$(ARCHS_STANDARD)";
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1370,8 +1373,8 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1389,7 +1392,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp.complications;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.watchkitapp.complications;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1409,9 +1412,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ARCHS = "$(ARCHS_STANDARD)";
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1420,8 +1423,8 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = FirkaWatchComplicationsExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1439,7 +1442,7 @@
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp.complications;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.watchkitapp.complications;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1459,9 +1462,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1471,8 +1474,8 @@
|
||||
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1491,7 +1494,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.HomeWidgetsExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.HomeWidgetsExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1510,9 +1513,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1522,8 +1525,8 @@
|
||||
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1540,7 +1543,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.HomeWidgetsExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.HomeWidgetsExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1557,9 +1560,9 @@
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB41CF90195004384FC /* WidgetExtension.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1569,8 +1572,8 @@
|
||||
CODE_SIGN_ENTITLEMENTS = HomeWidgetsExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1587,7 +1590,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.HomeWidgetsExtension;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.HomeWidgetsExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -1605,8 +1608,8 @@
|
||||
buildSettings = {
|
||||
ARCHS = "$(ARCHS_STANDARD)";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1616,14 +1619,15 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "FirkaWatch Watch App/FirkaWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firka;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1633,7 +1637,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = watchos;
|
||||
@@ -1657,8 +1661,8 @@
|
||||
buildSettings = {
|
||||
ARCHS = "$(ARCHS_STANDARD)";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1668,14 +1672,15 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "FirkaWatch Watch App/FirkaWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firka;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1683,7 +1688,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = watchos;
|
||||
@@ -1706,8 +1711,8 @@
|
||||
buildSettings = {
|
||||
ARCHS = "$(ARCHS_STANDARD)";
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AppIcon;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
@@ -1717,14 +1722,15 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "FirkaWatch Watch App/FirkaWatch Watch App.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FirkaWatch;
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firkaa;
|
||||
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = app.firka.firka;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -1732,7 +1738,7 @@
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa.watchkitapp;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.watchkitapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = watchos;
|
||||
@@ -1874,18 +1880,18 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Firka;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -1910,18 +1916,18 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1068;
|
||||
DEVELOPMENT_TEAM = UT7MSP4GWZ;
|
||||
CURRENT_PROJECT_VERSION = 1102;
|
||||
DEVELOPMENT_TEAM = R9PZGUCNJ3;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Firka Testing";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Firka;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.9;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firkaa;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
@@ -30,8 +30,8 @@ import BackgroundTasks
|
||||
widgetDeepLinkChannel = FlutterMethodChannel(name: "firka.app/widget_deep_link", binaryMessenger: controller.binaryMessenger)
|
||||
widgetDeepLinkChannel?.setMethodCallHandler { [weak self] (call: FlutterMethodCall, result: @escaping FlutterResult) in
|
||||
if call.method == "getPendingDeepLink" {
|
||||
if let controlNav = UserDefaults(suiteName: "group.app.firka.firkaa")?.string(forKey: "controlNavigation") {
|
||||
UserDefaults(suiteName: "group.app.firka.firkaa")?.removeObject(forKey: "controlNavigation")
|
||||
if let controlNav = UserDefaults(suiteName: "group.app.firka.firka")?.string(forKey: "controlNavigation") {
|
||||
UserDefaults(suiteName: "group.app.firka.firka")?.removeObject(forKey: "controlNavigation")
|
||||
result(controlNav)
|
||||
} else if let link = self?.pendingWidgetDeepLink {
|
||||
self?.pendingWidgetDeepLink = nil
|
||||
@@ -213,12 +213,12 @@ import BackgroundTasks
|
||||
let request = BGAppRefreshTaskRequest(identifier: backgroundTaskIdentifier)
|
||||
|
||||
// IMPORTANT: iOS may delay this based on system conditions and user behavior
|
||||
// The default setting is 30 minutes
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
|
||||
// Requested cadence: 15 minutes (best effort, not guaranteed by iOS)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
print("[AppDelegate] Background refresh scheduled for ~30 minutes from now")
|
||||
print("[AppDelegate] Background refresh scheduled for ~15 minutes from now")
|
||||
} catch {
|
||||
print("[AppDelegate] Could not schedule background refresh: \(error)")
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 660 B After Width: | Height: | Size: 652 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
@@ -11,7 +11,7 @@ class HomeWidgetMethodChannel {
|
||||
switch call.method {
|
||||
case "getAppGroupDirectory":
|
||||
if let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: "group.app.firka.firkaa"
|
||||
forSecurityApplicationGroupIdentifier: "group.app.firka.firka"
|
||||
) {
|
||||
result(containerURL.path)
|
||||
} else {
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.firka.timetable.refresh</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleAllowMixedLocalizations</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<dict>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>app.firka.timetable.refresh</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleAllowMixedLocalizations</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Firka Testing</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>hu</string>
|
||||
<string>de</string>
|
||||
</array>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Firka Testing</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>firka</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.firka.firkaa</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>firka</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1068</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<string>hu</string>
|
||||
<string>de</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>firka</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.firka.firka</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>firka</string>
|
||||
</array>
|
||||
</dict>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1102</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
<string>fetch</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -4,13 +4,17 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)app.firka.firka</string>
|
||||
<key>com.apple.developer.usernotifications.time-sensitive</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.firka.firkaa</string>
|
||||
<string>group.app.firka.firka</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)app.firka.shared</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)app.firka.firkaa</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -9,11 +9,30 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
private var isFlutterWatchSyncReady = false
|
||||
private var pendingAuthPayloads: [[String: Any]] = []
|
||||
private var pendingICloudRecoveryNotification = false
|
||||
private let pendingAuthQueue = DispatchQueue(label: "app.firka.pendingAuthQueue")
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
private func mergeApplicationContext(_ newEntries: [String: Any]) {
|
||||
let session = WCSession.default
|
||||
guard session.activationState == .activated else { return }
|
||||
|
||||
var merged = session.applicationContext
|
||||
for (key, value) in newEntries {
|
||||
merged[key] = value
|
||||
}
|
||||
if !newEntries.keys.contains("force_logout") {
|
||||
merged.removeValue(forKey: "force_logout")
|
||||
}
|
||||
do {
|
||||
try session.updateApplicationContext(merged)
|
||||
} catch {
|
||||
print("[WatchSessionManager] Failed to merge applicationContext: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func setup(with messenger: FlutterBinaryMessenger) {
|
||||
flutterChannel = FlutterMethodChannel(
|
||||
name: "app.firka/watch_sync",
|
||||
@@ -36,8 +55,30 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
self?.handleCheckiCloudToken(result: result)
|
||||
case "saveTokeToniCloud":
|
||||
self?.handleSaveTokenToiCloud(arguments: call.arguments, result: result)
|
||||
case "isWatchAppInstalled":
|
||||
self?.handleIsWatchAppInstalled(result: result)
|
||||
case "isWatchReachable":
|
||||
self?.handleIsWatchReachable(result: result)
|
||||
case "clearICloudToken":
|
||||
self?.handleClearICloudToken(result: result)
|
||||
case "sendLogoutToWatch":
|
||||
self?.handleSendLogoutToWatch(result: result)
|
||||
case "watchSyncReady":
|
||||
self?.handleWatchSyncReady(result: result)
|
||||
case "waitForPeerRefreshLease":
|
||||
self?.handleWaitForPeerRefreshLease(arguments: call.arguments, result: result)
|
||||
case "acquireRefreshLease":
|
||||
self?.handleAcquireRefreshLease(arguments: call.arguments, result: result)
|
||||
case "releaseRefreshLease":
|
||||
self?.handleReleaseRefreshLease(arguments: call.arguments, result: result)
|
||||
case "clearRefreshLeaseForAccount":
|
||||
self?.handleClearRefreshLeaseForAccount(arguments: call.arguments, result: result)
|
||||
case "clearAllRefreshLeases":
|
||||
self?.handleClearAllRefreshLeases(result: result)
|
||||
case "clearSharedLanguageState":
|
||||
self?.handleClearSharedLanguageState(result: result)
|
||||
case "sendMessageToWatch":
|
||||
self?.handleSendMessageToWatch(arguments: call.arguments, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
@@ -82,6 +123,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func parseLeaseOwner(_ value: Any?) -> RefreshLeaseOwner? {
|
||||
guard let raw = value as? String else {
|
||||
return nil
|
||||
}
|
||||
return RefreshLeaseOwner(rawValue: raw)
|
||||
}
|
||||
|
||||
private func tokenPayload(from token: WatchToken) -> [String: Any] {
|
||||
var tokenData: [String: Any] = [
|
||||
"studentId": token.studentId,
|
||||
@@ -101,8 +149,16 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return tokenData
|
||||
}
|
||||
|
||||
private func fallbackTokenFromiCloud() -> [String: Any]? {
|
||||
guard let token = iCloudTokenManager.shared.loadToken() else {
|
||||
private func isTokenUsable(_ token: WatchToken, skewSeconds: TimeInterval = 60) -> Bool {
|
||||
token.expiryDate > Date().addingTimeInterval(skewSeconds)
|
||||
}
|
||||
|
||||
private func fallbackTokenFromSharedKeychain() -> [String: Any]? {
|
||||
guard let token = SharedKeychainManager.shared.loadToken() else {
|
||||
return nil
|
||||
}
|
||||
guard isTokenUsable(token, skewSeconds: 0) else {
|
||||
print("[WatchSessionManager] Shared Keychain fallback token is expired, skipping fallback")
|
||||
return nil
|
||||
}
|
||||
return tokenPayload(from: token)
|
||||
@@ -116,12 +172,22 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
(lhs["refreshToken"] as? String) == (rhs["refreshToken"] as? String)
|
||||
}
|
||||
|
||||
private func enqueuePendingAuth(_ authData: [String: Any]) {
|
||||
if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) {
|
||||
return
|
||||
private func tokenPayloadIsUsable(_ tokenData: [String: Any], skewMs: Int64 = 0) -> Bool {
|
||||
guard let expiryMs = parseInt64(tokenData["expiryDate"]) else {
|
||||
return false
|
||||
}
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
return expiryMs > (nowMs + skewMs)
|
||||
}
|
||||
|
||||
private func enqueuePendingAuth(_ authData: [String: Any]) {
|
||||
pendingAuthQueue.sync {
|
||||
if pendingAuthPayloads.contains(where: { sameTokenPayload($0, authData) }) {
|
||||
return
|
||||
}
|
||||
pendingAuthPayloads.append(authData)
|
||||
print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready")
|
||||
}
|
||||
pendingAuthPayloads.append(authData)
|
||||
print("[WatchSessionManager] Queued pending token from Watch until Flutter sync is ready")
|
||||
}
|
||||
|
||||
private func forwardTokenToFlutter(_ authData: [String: Any]) {
|
||||
@@ -145,13 +211,19 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
guard isFlutterWatchSyncReady else {
|
||||
return
|
||||
}
|
||||
if !pendingAuthPayloads.isEmpty {
|
||||
print("[WatchSessionManager] Flushing \(pendingAuthPayloads.count) queued token event(s) to Flutter")
|
||||
|
||||
let payloadsToFlush: [[String: Any]] = pendingAuthQueue.sync {
|
||||
let copy = pendingAuthPayloads
|
||||
pendingAuthPayloads.removeAll()
|
||||
return copy
|
||||
}
|
||||
for authData in pendingAuthPayloads {
|
||||
|
||||
if !payloadsToFlush.isEmpty {
|
||||
print("[WatchSessionManager] Flushing \(payloadsToFlush.count) queued token event(s) to Flutter")
|
||||
}
|
||||
for authData in payloadsToFlush {
|
||||
flutterChannel?.invokeMethod("onTokenFromWatch", arguments: authData)
|
||||
}
|
||||
pendingAuthPayloads.removeAll()
|
||||
|
||||
if pendingICloudRecoveryNotification {
|
||||
pendingICloudRecoveryNotification = false
|
||||
@@ -172,21 +244,43 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.isWatchAppInstalled else {
|
||||
print("[WatchSessionManager] No paired Watch app, skipping token send")
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.activationState == .activated else {
|
||||
result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
WCSession.default.transferUserInfo([
|
||||
"id": "token_update",
|
||||
"auth": authData
|
||||
])
|
||||
result(nil)
|
||||
print("[WatchSessionManager] Token sent to Watch")
|
||||
} catch {
|
||||
result(FlutterError(code: "TRANSFER_ERROR", message: error.localizedDescription, details: nil))
|
||||
let session = WCSession.default
|
||||
|
||||
mergeApplicationContext(["auth": authData])
|
||||
|
||||
session.transferUserInfo([
|
||||
"id": "token_update",
|
||||
"auth": authData
|
||||
])
|
||||
|
||||
if session.isReachable {
|
||||
session.sendMessage(
|
||||
[
|
||||
"id": "token_update",
|
||||
"auth": authData
|
||||
],
|
||||
replyHandler: { _ in
|
||||
print("[WatchSessionManager] Token delivered to Watch via sendMessage")
|
||||
},
|
||||
errorHandler: { error in
|
||||
print("[WatchSessionManager] Failed immediate token send via sendMessage: \(error.localizedDescription)")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
result(nil)
|
||||
print("[WatchSessionManager] Token sent to Watch")
|
||||
}
|
||||
|
||||
private func handleSendWidgetDataToWatch(arguments: Any?, result: @escaping FlutterResult) {
|
||||
@@ -200,13 +294,9 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try WCSession.default.updateApplicationContext(["widget_data": jsonString])
|
||||
result(nil)
|
||||
print("[WatchSessionManager] Widget data sent to Watch")
|
||||
} catch {
|
||||
result(FlutterError(code: "UPDATE_ERROR", message: error.localizedDescription, details: nil))
|
||||
}
|
||||
mergeApplicationContext(["widget_data": jsonString])
|
||||
result(nil)
|
||||
print("[WatchSessionManager] Widget data sent to Watch")
|
||||
}
|
||||
|
||||
private func handleSendLanguageToWatch(arguments: Any?, result: @escaping FlutterResult) {
|
||||
@@ -215,15 +305,29 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.activationState == .activated else {
|
||||
result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil))
|
||||
guard WCSession.default.isWatchAppInstalled else {
|
||||
print("[WatchSessionManager] No paired Watch app, skipping language publish")
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
WCSession.default.transferUserInfo([
|
||||
"id": "language_update",
|
||||
"language": languageCode
|
||||
])
|
||||
let sharedState = SharedLanguageStateManager.shared.publishState(languageCode: languageCode)
|
||||
|
||||
if WCSession.default.activationState == .activated {
|
||||
mergeApplicationContext([
|
||||
"language": languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch via applicationContext")
|
||||
|
||||
WCSession.default.transferUserInfo([
|
||||
"id": "language_update",
|
||||
"language": languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else {
|
||||
print("[WatchSessionManager] WCSession not active, language shared-state published only")
|
||||
}
|
||||
result(nil)
|
||||
print("[WatchSessionManager] Language '\(languageCode)' sent to Watch")
|
||||
}
|
||||
@@ -275,17 +379,17 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
|
||||
private func handleCheckiCloudToken(result: @escaping FlutterResult) {
|
||||
print("[WatchSessionManager] Checking iCloud for token...")
|
||||
print("[WatchSessionManager] Checking shared Keychain for token...")
|
||||
|
||||
guard let token = iCloudTokenManager.shared.loadToken() else {
|
||||
print("[WatchSessionManager] No token in iCloud")
|
||||
guard let token = SharedKeychainManager.shared.loadToken() else {
|
||||
print("[WatchSessionManager] No token in shared Keychain")
|
||||
result(["error": "no_token"])
|
||||
return
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
print("[WatchSessionManager] Found iCloud token, expiry: \(formatter.string(from: token.expiryDate))")
|
||||
print("[WatchSessionManager] Found shared Keychain token, expiry: \(formatter.string(from: token.expiryDate))")
|
||||
|
||||
result(tokenPayload(from: token))
|
||||
}
|
||||
@@ -296,6 +400,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.isWatchAppInstalled else {
|
||||
print("[WatchSessionManager] No paired Watch app, skipping token save to shared Keychain")
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let accessToken = tokenData["accessToken"] as? String,
|
||||
let refreshToken = tokenData["refreshToken"] as? String,
|
||||
let idToken = tokenData["idToken"] as? String,
|
||||
@@ -323,15 +433,220 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
updatedAtMs: updatedAtMs
|
||||
)
|
||||
|
||||
iCloudTokenManager.shared.saveToken(token, deviceName: "iPhone")
|
||||
let forceAccountSwitch = (tokenData["forceAccountSwitch"] as? Bool) == true
|
||||
let didSave = SharedKeychainManager.shared.saveToken(
|
||||
token,
|
||||
forceAccountSwitch: forceAccountSwitch
|
||||
)
|
||||
if WCSession.default.isWatchAppInstalled {
|
||||
if didSave {
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: true,
|
||||
activeStudentIdNorm: studentIdNorm
|
||||
)
|
||||
} else if SharedKeychainManager.shared.loadToken()?.studentIdNorm == studentIdNorm {
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: true,
|
||||
activeStudentIdNorm: studentIdNorm
|
||||
)
|
||||
} else {
|
||||
print("[WatchSessionManager] Token save skipped (stale/cross-account); skipping session-state publish for \(studentIdNorm)")
|
||||
}
|
||||
}
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
print("[WatchSessionManager] Token saved to iCloud, expiry: \(formatter.string(from: expiryDate))")
|
||||
print("[WatchSessionManager] Token saved to shared Keychain, expiry: \(formatter.string(from: expiryDate))")
|
||||
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleIsWatchAppInstalled(result: @escaping FlutterResult) {
|
||||
guard WCSession.isSupported() else {
|
||||
result(false)
|
||||
return
|
||||
}
|
||||
|
||||
let session = WCSession.default
|
||||
let installed = session.isPaired && session.isWatchAppInstalled
|
||||
result(installed)
|
||||
}
|
||||
|
||||
private func handleIsWatchReachable(result: @escaping FlutterResult) {
|
||||
guard WCSession.isSupported() else {
|
||||
result(false)
|
||||
return
|
||||
}
|
||||
|
||||
let session = WCSession.default
|
||||
let reachable =
|
||||
session.isPaired &&
|
||||
session.isWatchAppInstalled &&
|
||||
session.activationState == .activated &&
|
||||
session.isReachable
|
||||
result(reachable)
|
||||
}
|
||||
|
||||
private func handleClearICloudToken(result: @escaping FlutterResult) {
|
||||
SharedKeychainManager.shared.deleteToken()
|
||||
|
||||
SharedKeychainManager.shared.clearKVStore()
|
||||
RefreshLeaseManager.shared.clearAllLeases()
|
||||
SharedLanguageStateManager.shared.clearState()
|
||||
if WCSession.default.isWatchAppInstalled {
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: false,
|
||||
activeStudentIdNorm: nil
|
||||
)
|
||||
} else {
|
||||
SharedSessionStateManager.shared.clearState()
|
||||
}
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleWaitForPeerRefreshLease(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let args = arguments as? [String: Any],
|
||||
let owner = parseLeaseOwner(args["owner"]),
|
||||
let studentIdNorm = parseInt64(args["studentIdNorm"]) else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Invalid wait lease arguments", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let maxWaitMs = parseInt64(args["maxWaitMs"]) ?? 150_000
|
||||
let pollMs = parseInt64(args["pollIntervalMs"]) ?? 250
|
||||
|
||||
if owner == .iphone && !WCSession.default.isWatchAppInstalled {
|
||||
result([
|
||||
"ready": true,
|
||||
"status": "no_watch",
|
||||
"waitedMs": 0,
|
||||
"leaseChanged": false
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let waitResult = await RefreshLeaseManager.shared.waitForPeerLeaseRelease(
|
||||
owner: owner,
|
||||
studentIdNorm: studentIdNorm,
|
||||
maxWaitMs: maxWaitMs,
|
||||
pollIntervalMs: pollMs
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
result(waitResult.asDictionary())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAcquireRefreshLease(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let args = arguments as? [String: Any],
|
||||
let owner = parseLeaseOwner(args["owner"]),
|
||||
let studentIdNorm = parseInt64(args["studentIdNorm"]) else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Invalid acquire lease arguments", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
if owner == .iphone && !WCSession.default.isWatchAppInstalled {
|
||||
result([
|
||||
"skipped": true,
|
||||
"status": "no_watch"
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let ttlMs = parseInt64(args["ttlMs"]) ?? 120_000
|
||||
let operationId = (args["operationId"] as? String) ?? UUID().uuidString
|
||||
let lease = RefreshLeaseManager.shared.acquireLease(
|
||||
owner: owner,
|
||||
studentIdNorm: studentIdNorm,
|
||||
ttlMs: ttlMs,
|
||||
operationId: operationId
|
||||
)
|
||||
result([
|
||||
"operationId": lease.operationId,
|
||||
"startedAtMs": lease.startedAtMs,
|
||||
"expiresAtMs": lease.expiresAtMs
|
||||
])
|
||||
}
|
||||
|
||||
private func handleReleaseRefreshLease(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let args = arguments as? [String: Any],
|
||||
let owner = parseLeaseOwner(args["owner"]),
|
||||
let studentIdNorm = parseInt64(args["studentIdNorm"]) else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Invalid release lease arguments", details: nil))
|
||||
return
|
||||
}
|
||||
let operationId = args["operationId"] as? String
|
||||
RefreshLeaseManager.shared.releaseLease(
|
||||
owner: owner,
|
||||
studentIdNorm: studentIdNorm,
|
||||
operationId: operationId
|
||||
)
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleClearRefreshLeaseForAccount(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let args = arguments as? [String: Any],
|
||||
let studentIdNorm = parseInt64(args["studentIdNorm"]) else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing studentIdNorm", details: nil))
|
||||
return
|
||||
}
|
||||
RefreshLeaseManager.shared.clearLeases(studentIdNorm: studentIdNorm)
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleClearAllRefreshLeases(result: @escaping FlutterResult) {
|
||||
RefreshLeaseManager.shared.clearAllLeases()
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleClearSharedLanguageState(result: @escaping FlutterResult) {
|
||||
SharedLanguageStateManager.shared.clearState()
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleSendLogoutToWatch(result: @escaping FlutterResult) {
|
||||
guard WCSession.default.activationState == .activated else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.isWatchAppInstalled else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
mergeApplicationContext(["force_logout": true])
|
||||
|
||||
WCSession.default.transferUserInfo([
|
||||
"id": "force_logout"
|
||||
])
|
||||
print("[WatchSessionManager] Force logout sent to Watch")
|
||||
result(nil)
|
||||
}
|
||||
|
||||
private func handleSendMessageToWatch(arguments: Any?, result: @escaping FlutterResult) {
|
||||
guard let message = arguments as? [String: Any] else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Arguments must be a dictionary", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.activationState == .activated else {
|
||||
result(FlutterError(code: "SESSION_NOT_ACTIVE", message: "WCSession is not activated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard WCSession.default.isReachable else {
|
||||
result(FlutterError(code: "NOT_REACHABLE", message: "Watch is not reachable", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
WCSession.default.sendMessage(message, replyHandler: nil, errorHandler: { error in
|
||||
print("[WatchSessionManager] Failed to send message to Watch: \(error.localizedDescription)")
|
||||
})
|
||||
result(nil)
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
@@ -369,6 +684,12 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
didReceiveMessage message: [String: Any],
|
||||
replyHandler: @escaping ([String: Any]) -> Void
|
||||
) {
|
||||
DispatchQueue.main.async { [self] in
|
||||
self._handleMessageWithReply(message: message, replyHandler: replyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
private func _handleMessageWithReply(message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
|
||||
print("[WatchSessionManager] Received message from Watch: \(message)")
|
||||
|
||||
guard let action = message["action"] as? String else {
|
||||
@@ -379,7 +700,7 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
switch action {
|
||||
case "requestToken":
|
||||
if !self.isFlutterWatchSyncReady {
|
||||
if let tokenData = self.fallbackTokenFromiCloud() {
|
||||
if let tokenData = self.fallbackTokenFromSharedKeychain() {
|
||||
print("[WatchSessionManager] Flutter not ready, returning iCloud token to Watch")
|
||||
replyHandler(["auth": tokenData])
|
||||
} else {
|
||||
@@ -388,11 +709,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in
|
||||
self.flutterChannel?.invokeMethod("getTokenForWatch", arguments: nil) { result in
|
||||
if let tokenData = result as? [String: Any] {
|
||||
if let error = tokenData["error"] as? String {
|
||||
if let fallbackToken = self.fallbackTokenFromiCloud() {
|
||||
if error == "needsReauth" {
|
||||
print("[WatchSessionManager] Flutter reported needsReauth, not using iCloud fallback")
|
||||
replyHandler(["error": error])
|
||||
} else if let fallbackToken = self.fallbackTokenFromSharedKeychain() {
|
||||
print("[WatchSessionManager] Flutter returned error (\(error)), falling back to iCloud token")
|
||||
replyHandler(["auth": fallbackToken])
|
||||
} else {
|
||||
@@ -400,11 +723,16 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
replyHandler(["error": error])
|
||||
}
|
||||
} else {
|
||||
guard self.tokenPayloadIsUsable(tokenData) else {
|
||||
print("[WatchSessionManager] Flutter token is expired, refusing to send to Watch")
|
||||
replyHandler(["error": "needsReauth"])
|
||||
return
|
||||
}
|
||||
print("[WatchSessionManager] Sending token to Watch")
|
||||
replyHandler(["auth": tokenData])
|
||||
}
|
||||
} else {
|
||||
if let fallbackToken = self.fallbackTokenFromiCloud() {
|
||||
if let fallbackToken = self.fallbackTokenFromSharedKeychain() {
|
||||
print("[WatchSessionManager] No Flutter token available, falling back to iCloud token")
|
||||
replyHandler(["auth": fallbackToken])
|
||||
} else {
|
||||
@@ -413,18 +741,86 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "requestLanguage":
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("getLanguageForWatch", arguments: nil) { result in
|
||||
if let languageCode = result as? String {
|
||||
print("[WatchSessionManager] Sending language to Watch: \(languageCode)")
|
||||
replyHandler(["language": languageCode])
|
||||
} else {
|
||||
print("[WatchSessionManager] No language from Flutter, defaulting to hu")
|
||||
replyHandler(["language": "hu"])
|
||||
if !self.isFlutterWatchSyncReady {
|
||||
if let sharedState = SharedLanguageStateManager.shared.loadState() {
|
||||
print("[WatchSessionManager] Flutter not ready for language request, serving shared language: \(sharedState.languageCode)")
|
||||
replyHandler([
|
||||
"language": sharedState.languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter not ready for language request and no shared language is available")
|
||||
replyHandler(["error": "language_not_ready"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let flutterChannel = self.flutterChannel else {
|
||||
if let sharedState = SharedLanguageStateManager.shared.loadState() {
|
||||
print("[WatchSessionManager] Flutter channel missing for language request, serving shared language: \(sharedState.languageCode)")
|
||||
replyHandler([
|
||||
"language": sharedState.languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter channel missing for language request and no shared language is available")
|
||||
replyHandler(["error": "language_not_ready"])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var hasReplied = false
|
||||
let timeoutWorkItem = DispatchWorkItem {
|
||||
guard !hasReplied else { return }
|
||||
hasReplied = true
|
||||
if let sharedState = SharedLanguageStateManager.shared.loadState() {
|
||||
print("[WatchSessionManager] Flutter timeout, serving shared language: \(sharedState.languageCode)")
|
||||
replyHandler([
|
||||
"language": sharedState.languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter timeout and no shared language available")
|
||||
replyHandler(["error": "timeout"])
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: timeoutWorkItem)
|
||||
|
||||
flutterChannel.invokeMethod("getLanguageForWatch", arguments: nil) { result in
|
||||
timeoutWorkItem.cancel()
|
||||
guard !hasReplied else { return }
|
||||
hasReplied = true
|
||||
|
||||
if let languageCode = result as? String, !languageCode.isEmpty {
|
||||
if let existingState = SharedLanguageStateManager.shared.loadState(),
|
||||
existingState.languageCode == languageCode {
|
||||
print("[WatchSessionManager] Sending language to Watch from shared cache: \(languageCode)")
|
||||
replyHandler([
|
||||
"language": languageCode,
|
||||
"language_state_version": existingState.stateVersion
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
let sharedState = SharedLanguageStateManager.shared.publishState(
|
||||
languageCode: languageCode
|
||||
)
|
||||
print("[WatchSessionManager] Sending language to Watch: \(languageCode)")
|
||||
replyHandler([
|
||||
"language": languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else if let sharedState = SharedLanguageStateManager.shared.loadState() {
|
||||
print("[WatchSessionManager] No language from Flutter, serving last shared language: \(sharedState.languageCode)")
|
||||
replyHandler([
|
||||
"language": sharedState.languageCode,
|
||||
"language_state_version": sharedState.stateVersion
|
||||
])
|
||||
} else {
|
||||
print("[WatchSessionManager] No language available from Flutter or shared state")
|
||||
replyHandler(["error": "language_not_ready"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,27 +832,23 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
|
||||
if !self.isFlutterWatchSyncReady {
|
||||
print("[WatchSessionManager] Flutter not ready, queueing token from Watch")
|
||||
DispatchQueue.main.async {
|
||||
self.enqueuePendingAuth(tokenData)
|
||||
}
|
||||
self.enqueuePendingAuth(tokenData)
|
||||
replyHandler(["success": true])
|
||||
return
|
||||
}
|
||||
|
||||
print("[WatchSessionManager] Receiving token from Watch")
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in
|
||||
if let success = result as? Bool, success {
|
||||
print("[WatchSessionManager] Flutter accepted Watch token")
|
||||
replyHandler(["success": true])
|
||||
} else if let resultDict = result as? [String: Any],
|
||||
let success = resultDict["success"] as? Bool, success {
|
||||
print("[WatchSessionManager] Flutter accepted Watch token")
|
||||
replyHandler(["success": true])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter rejected Watch token")
|
||||
replyHandler(["error": "rejected"])
|
||||
}
|
||||
self.flutterChannel?.invokeMethod("onTokenFromWatch", arguments: tokenData) { result in
|
||||
if let success = result as? Bool, success {
|
||||
print("[WatchSessionManager] Flutter accepted Watch token")
|
||||
replyHandler(["success": true])
|
||||
} else if let resultDict = result as? [String: Any],
|
||||
let success = resultDict["success"] as? Bool, success {
|
||||
print("[WatchSessionManager] Flutter accepted Watch token")
|
||||
replyHandler(["success": true])
|
||||
} else {
|
||||
print("[WatchSessionManager] Flutter rejected Watch token")
|
||||
replyHandler(["error": "rejected"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +857,13 @@ class WatchSessionManager: NSObject, WCSessionDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
print("[WatchSessionManager] Received fire-and-forget message from Watch: \(message)")
|
||||
DispatchQueue.main.async {
|
||||
self.flutterChannel?.invokeMethod("onWatchMessage", arguments: message)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {
|
||||
print("[WatchSessionManager] Session did become inactive")
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ class KretaAPIClient {
|
||||
if let token = TokenManager.shared.loadToken() {
|
||||
let expiryThreshold = token.expiryDate.addingTimeInterval(-60)
|
||||
if Date() < expiryThreshold {
|
||||
TokenManager.shared.clearLastRecoveryFailure()
|
||||
return token
|
||||
}
|
||||
}
|
||||
@@ -97,9 +98,15 @@ class KretaAPIClient {
|
||||
print("[KretaAPI] Token invalid or expired, starting recovery...")
|
||||
if let recoveredToken = await TokenManager.shared.recoverToken() {
|
||||
print("[KretaAPI] Token recovery succeeded")
|
||||
TokenManager.shared.clearLastRecoveryFailure()
|
||||
return recoveredToken
|
||||
}
|
||||
|
||||
if let recoveryFailure = TokenManager.shared.lastRecoveryFailure {
|
||||
print("[KretaAPI] Token recovery failed with classified error: \(recoveryFailure)")
|
||||
throw APIError.tokenError(recoveryFailure)
|
||||
}
|
||||
|
||||
print("[KretaAPI] Token recovery failed")
|
||||
throw APIError.tokenError(.noToken)
|
||||
}
|
||||
|
||||
791
firka/ios/Shared/API/SharedKeychainManager.swift
Normal file
@@ -0,0 +1,791 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Manages the synced Keychain storage for cross-device token sharing via iCloud Keychain.
|
||||
class SharedKeychainManager {
|
||||
static let shared = SharedKeychainManager()
|
||||
|
||||
private let accessGroupSuffix = "app.firka.shared"
|
||||
private lazy var accessGroup: String = resolveAccessGroup()
|
||||
private let service = "app.firka.shared.token"
|
||||
private let account = "syncedToken"
|
||||
|
||||
#if os(iOS)
|
||||
private let deviceName = "iPhone"
|
||||
#elseif os(watchOS)
|
||||
private let deviceName = "Watch"
|
||||
#endif
|
||||
|
||||
private init() {}
|
||||
|
||||
var resolvedAccessGroup: String {
|
||||
accessGroup
|
||||
}
|
||||
|
||||
private func resolveAccessGroup() -> String {
|
||||
let probeService = "\(service).probe"
|
||||
let probeAccount = "probe"
|
||||
let probeValue = Data("probe".utf8)
|
||||
|
||||
let cleanupQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: probeService,
|
||||
kSecAttrAccount as String: probeAccount,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
SecItemDelete(cleanupQuery as CFDictionary)
|
||||
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: probeService,
|
||||
kSecAttrAccount as String: probeAccount,
|
||||
kSecValueData as String: probeValue,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
if addStatus == errSecSuccess || addStatus == errSecDuplicateItem {
|
||||
let readQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: probeService,
|
||||
kSecAttrAccount as String: probeAccount,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let readStatus = SecItemCopyMatching(readQuery as CFDictionary, &result)
|
||||
SecItemDelete(cleanupQuery as CFDictionary)
|
||||
|
||||
if readStatus == errSecSuccess,
|
||||
let attributes = result as? [String: Any],
|
||||
let resolvedGroup = attributes[kSecAttrAccessGroup as String] as? String,
|
||||
!resolvedGroup.isEmpty {
|
||||
print("[SharedKeychain] Resolved access group: \(resolvedGroup)")
|
||||
return resolvedGroup
|
||||
}
|
||||
}
|
||||
|
||||
print("[SharedKeychain] Failed to resolve access group dynamically, using suffix fallback")
|
||||
return accessGroupSuffix
|
||||
}
|
||||
|
||||
// MARK: - Save Token (Synced)
|
||||
@discardableResult
|
||||
func saveToken(_ token: WatchToken, forceAccountSwitch: Bool = false) -> Bool {
|
||||
if let existingToken = loadToken() {
|
||||
if existingToken.isSameAccount(as: token) {
|
||||
if !token.isNewer(than: existingToken) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
print("[SharedKeychain] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))")
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
if !forceAccountSwitch {
|
||||
let incomingUpdatedAt = token.effectiveUpdatedAtMs ?? 0
|
||||
let existingUpdatedAt = existingToken.effectiveUpdatedAtMs ?? 0
|
||||
if incomingUpdatedAt > 0 &&
|
||||
existingUpdatedAt > 0 &&
|
||||
incomingUpdatedAt <= existingUpdatedAt {
|
||||
print("[SharedKeychain] Ignoring cross-account stale token save from \(deviceName)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("[SharedKeychain] Saving token to synced Keychain from \(deviceName)")
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(token)
|
||||
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
|
||||
]
|
||||
|
||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
|
||||
if status == errSecSuccess {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
print("[SharedKeychain] Token saved successfully to synced Keychain, expiry: \(formatter.string(from: token.expiryDate))")
|
||||
return true
|
||||
} else {
|
||||
print("[SharedKeychain] Failed to save token to synced Keychain: \(status)")
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
print("[SharedKeychain] Failed to encode token: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Load Token (Synced)
|
||||
func loadToken() -> WatchToken? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
if status != errSecItemNotFound {
|
||||
print("[SharedKeychain] Failed to load token from synced Keychain: \(status)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let token = try decoder.decode(WatchToken.self, from: data)
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
print("[SharedKeychain] Token loaded from synced Keychain, expiry: \(formatter.string(from: token.expiryDate))")
|
||||
|
||||
return token
|
||||
} catch {
|
||||
print("[SharedKeychain] Failed to decode token from synced Keychain: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete Token (Synced)
|
||||
func deleteToken() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status == errSecSuccess || status == errSecItemNotFound {
|
||||
print("[SharedKeychain] Token deleted from synced Keychain")
|
||||
} else {
|
||||
print("[SharedKeychain] Failed to delete token from synced Keychain: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Migration from KV Store
|
||||
func migrateFromKVStoreAndClear() -> WatchToken? {
|
||||
let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||
|
||||
iCloudStore.synchronize()
|
||||
|
||||
guard let accessToken = iCloudStore.string(forKey: "firka_access_token"),
|
||||
let refreshToken = iCloudStore.string(forKey: "firka_refresh_token"),
|
||||
let idToken = iCloudStore.string(forKey: "firka_id_token"),
|
||||
let iss = iCloudStore.string(forKey: "firka_iss"),
|
||||
let studentId = iCloudStore.string(forKey: "firka_student_id") else {
|
||||
print("[SharedKeychain] No token found in KV Store to migrate")
|
||||
clearKVStore()
|
||||
return nil
|
||||
}
|
||||
|
||||
let studentIdNorm = iCloudStore.longLong(forKey: "firka_student_id_norm")
|
||||
let expiryTimestamp = iCloudStore.double(forKey: "firka_expiry_date")
|
||||
let tokenVersionRaw = iCloudStore.longLong(forKey: "firka_token_version")
|
||||
let updatedAtMsRaw = iCloudStore.longLong(forKey: "firka_updated_at_ms")
|
||||
|
||||
guard expiryTimestamp > 0 else {
|
||||
print("[SharedKeychain] Invalid expiry date in KV Store")
|
||||
clearKVStore()
|
||||
return nil
|
||||
}
|
||||
|
||||
let expiryDate = Date(timeIntervalSince1970: expiryTimestamp)
|
||||
|
||||
let token = WatchToken(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
idToken: idToken,
|
||||
iss: iss,
|
||||
studentId: studentId,
|
||||
studentIdNorm: studentIdNorm,
|
||||
expiryDate: expiryDate,
|
||||
tokenVersion: tokenVersionRaw > 0 ? tokenVersionRaw : nil,
|
||||
updatedAtMs: updatedAtMsRaw > 0 ? updatedAtMsRaw : nil
|
||||
)
|
||||
|
||||
print("[SharedKeychain] Migrated token from KV Store, expiry: \(expiryDate)")
|
||||
|
||||
clearKVStore()
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
func clearKVStore() {
|
||||
let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||
|
||||
let keysToRemove = [
|
||||
"firka_access_token",
|
||||
"firka_refresh_token",
|
||||
"firka_id_token",
|
||||
"firka_iss",
|
||||
"firka_student_id",
|
||||
"firka_student_id_norm",
|
||||
"firka_expiry_date",
|
||||
"firka_token_version",
|
||||
"firka_updated_at_ms",
|
||||
"firka_last_updated_device",
|
||||
"firka_last_update_timestamp"
|
||||
]
|
||||
|
||||
for key in keysToRemove {
|
||||
iCloudStore.removeObject(forKey: key)
|
||||
}
|
||||
|
||||
iCloudStore.synchronize()
|
||||
print("[SharedKeychain] Cleared old KV Store data")
|
||||
}
|
||||
}
|
||||
|
||||
enum RefreshLeaseOwner: String {
|
||||
case iphone
|
||||
case watch
|
||||
|
||||
var peer: RefreshLeaseOwner {
|
||||
switch self {
|
||||
case .iphone:
|
||||
return .watch
|
||||
case .watch:
|
||||
return .iphone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedSessionStateRecord: Codable {
|
||||
let stateVersion: Int64
|
||||
let hasAnyAccount: Bool
|
||||
let activeStudentIdNorm: Int64?
|
||||
let updatedAtMs: Int64
|
||||
let sourceDevice: String
|
||||
}
|
||||
|
||||
struct SharedLanguageStateRecord: Codable {
|
||||
let stateVersion: Int64
|
||||
let languageCode: String
|
||||
let updatedAtMs: Int64
|
||||
let expiresAtMs: Int64
|
||||
let sourceDevice: String
|
||||
}
|
||||
|
||||
class SharedLanguageStateManager {
|
||||
static let shared = SharedLanguageStateManager()
|
||||
|
||||
private let service = "app.firka.shared.language_state"
|
||||
private let account = "language_state"
|
||||
private let accessGroup: String
|
||||
private let maxTtlMs: Int64 = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
#if os(iOS)
|
||||
private let sourceDevice = "iphone"
|
||||
#elseif os(watchOS)
|
||||
private let sourceDevice = "watch"
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
|
||||
}
|
||||
|
||||
private func nowMs() -> Int64 {
|
||||
Int64(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
private func encode(_ state: SharedLanguageStateRecord) -> Data? {
|
||||
try? JSONEncoder().encode(state)
|
||||
}
|
||||
|
||||
private func decode(_ data: Data) -> SharedLanguageStateRecord? {
|
||||
try? JSONDecoder().decode(SharedLanguageStateRecord.self, from: data)
|
||||
}
|
||||
|
||||
private func keychainQueryBase() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup
|
||||
]
|
||||
}
|
||||
|
||||
private func loadStateFromKeychain() -> SharedLanguageStateRecord? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return decode(data)
|
||||
}
|
||||
|
||||
private func storeStateInKeychain(_ state: SharedLanguageStateRecord) {
|
||||
guard let data = encode(state) else {
|
||||
print("[SharedLanguageState] Failed to encode state for keychain")
|
||||
return
|
||||
}
|
||||
|
||||
var deleteQuery = keychainQueryBase()
|
||||
deleteQuery[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
var addQuery = keychainQueryBase()
|
||||
addQuery[kSecAttrSynchronizable as String] = kCFBooleanTrue!
|
||||
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
addQuery[kSecValueData as String] = data
|
||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
if status != errSecSuccess {
|
||||
print("[SharedLanguageState] Failed to publish state to keychain: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func clearKeychainState() {
|
||||
var query = keychainQueryBase()
|
||||
query[kSecAttrSynchronizable as String] = kSecAttrSynchronizableAny
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
private func isExpired(_ state: SharedLanguageStateRecord, now: Int64) -> Bool {
|
||||
state.expiresAtMs <= now
|
||||
}
|
||||
|
||||
func loadState() -> SharedLanguageStateRecord? {
|
||||
let now = nowMs()
|
||||
guard let keychainState = loadStateFromKeychain() else {
|
||||
return nil
|
||||
}
|
||||
if isExpired(keychainState, now: now) {
|
||||
clearKeychainState()
|
||||
return nil
|
||||
}
|
||||
return keychainState
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func publishState(
|
||||
languageCode: String,
|
||||
ttlMs: Int64 = 24 * 60 * 60 * 1000
|
||||
) -> SharedLanguageStateRecord {
|
||||
let now = nowMs()
|
||||
let previousVersion = loadStateFromKeychain()?.stateVersion ?? 0
|
||||
let nextVersion = max(now, previousVersion + 1)
|
||||
let effectiveTtl = max(min(ttlMs, maxTtlMs), 60_000)
|
||||
let state = SharedLanguageStateRecord(
|
||||
stateVersion: nextVersion,
|
||||
languageCode: languageCode,
|
||||
updatedAtMs: now,
|
||||
expiresAtMs: now + effectiveTtl,
|
||||
sourceDevice: sourceDevice
|
||||
)
|
||||
|
||||
storeStateInKeychain(state)
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
func clearState() {
|
||||
clearKeychainState()
|
||||
}
|
||||
}
|
||||
|
||||
class SharedSessionStateManager {
|
||||
static let shared = SharedSessionStateManager()
|
||||
|
||||
private let service = "app.firka.shared.session_state"
|
||||
private let account = "session_state"
|
||||
private let accessGroup: String
|
||||
|
||||
#if os(iOS)
|
||||
private let sourceDevice = "iphone"
|
||||
#elseif os(watchOS)
|
||||
private let sourceDevice = "watch"
|
||||
#endif
|
||||
|
||||
private init() {
|
||||
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
|
||||
}
|
||||
|
||||
private func nowMs() -> Int64 {
|
||||
Int64(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
private func encode(_ state: SharedSessionStateRecord) -> Data? {
|
||||
try? JSONEncoder().encode(state)
|
||||
}
|
||||
|
||||
private func decode(_ data: Data) -> SharedSessionStateRecord? {
|
||||
try? JSONDecoder().decode(SharedSessionStateRecord.self, from: data)
|
||||
}
|
||||
|
||||
func loadState() -> SharedSessionStateRecord? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return decode(data)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func publishState(
|
||||
hasAnyAccount: Bool,
|
||||
activeStudentIdNorm: Int64?
|
||||
) -> SharedSessionStateRecord {
|
||||
let now = nowMs()
|
||||
let previousVersion = loadState()?.stateVersion ?? 0
|
||||
let nextVersion = max(now, previousVersion + 1)
|
||||
let state = SharedSessionStateRecord(
|
||||
stateVersion: nextVersion,
|
||||
hasAnyAccount: hasAnyAccount,
|
||||
activeStudentIdNorm: hasAnyAccount ? activeStudentIdNorm : nil,
|
||||
updatedAtMs: now,
|
||||
sourceDevice: sourceDevice
|
||||
)
|
||||
|
||||
if let data = encode(state) {
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
if status != errSecSuccess {
|
||||
print("[SharedSessionState] Failed to publish state: \(status)")
|
||||
}
|
||||
} else {
|
||||
print("[SharedSessionState] Failed to encode state")
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
func clearState() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
struct RefreshLeaseRecord: Codable {
|
||||
let owner: String
|
||||
let studentIdNorm: Int64
|
||||
let operationId: String
|
||||
let startedAtMs: Int64
|
||||
let expiresAtMs: Int64
|
||||
}
|
||||
|
||||
struct RefreshLeaseWaitResult {
|
||||
let ready: Bool
|
||||
let status: String
|
||||
let waitedMs: Int64
|
||||
let peerOperationId: String?
|
||||
let leaseChanged: Bool
|
||||
|
||||
func asDictionary() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"ready": ready,
|
||||
"status": status,
|
||||
"waitedMs": waitedMs,
|
||||
"leaseChanged": leaseChanged
|
||||
]
|
||||
if let peerOperationId {
|
||||
dict["peerOperationId"] = peerOperationId
|
||||
}
|
||||
return dict
|
||||
}
|
||||
}
|
||||
|
||||
class RefreshLeaseManager {
|
||||
static let shared = RefreshLeaseManager()
|
||||
|
||||
private let service = "app.firka.shared.refresh_lease"
|
||||
private let accountPrefix = "lease"
|
||||
private let accessGroup: String
|
||||
|
||||
private init() {
|
||||
accessGroup = SharedKeychainManager.shared.resolvedAccessGroup
|
||||
}
|
||||
|
||||
private func keyAccount(
|
||||
owner: RefreshLeaseOwner,
|
||||
studentIdNorm: Int64
|
||||
) -> String {
|
||||
"\(accountPrefix)_\(owner.rawValue)_\(studentIdNorm)"
|
||||
}
|
||||
|
||||
private func nowMs() -> Int64 {
|
||||
Int64(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
private func encode(_ lease: RefreshLeaseRecord) -> Data? {
|
||||
try? JSONEncoder().encode(lease)
|
||||
}
|
||||
|
||||
private func decode(_ data: Data) -> RefreshLeaseRecord? {
|
||||
try? JSONDecoder().decode(RefreshLeaseRecord.self, from: data)
|
||||
}
|
||||
|
||||
func loadLease(
|
||||
owner: RefreshLeaseOwner,
|
||||
studentIdNorm: Int64
|
||||
) -> RefreshLeaseRecord? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: keyAccount(owner: owner, studentIdNorm: studentIdNorm),
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return decode(data)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func acquireLease(
|
||||
owner: RefreshLeaseOwner,
|
||||
studentIdNorm: Int64,
|
||||
ttlMs: Int64,
|
||||
operationId: String = UUID().uuidString
|
||||
) -> RefreshLeaseRecord {
|
||||
let now = nowMs()
|
||||
let clampedTtl = max(ttlMs, 5_000)
|
||||
let lease = RefreshLeaseRecord(
|
||||
owner: owner.rawValue,
|
||||
studentIdNorm: studentIdNorm,
|
||||
operationId: operationId,
|
||||
startedAtMs: now,
|
||||
expiresAtMs: now + clampedTtl
|
||||
)
|
||||
|
||||
if let data = encode(lease) {
|
||||
let account = keyAccount(owner: owner, studentIdNorm: studentIdNorm)
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
|
||||
let addQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kCFBooleanTrue!,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
let status = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
if status != errSecSuccess {
|
||||
print("[RefreshLease] Failed to acquire lease for \(owner.rawValue): \(status)")
|
||||
}
|
||||
} else {
|
||||
print("[RefreshLease] Failed to encode lease for \(owner.rawValue)")
|
||||
}
|
||||
|
||||
return lease
|
||||
}
|
||||
|
||||
func releaseLease(
|
||||
owner: RefreshLeaseOwner,
|
||||
studentIdNorm: Int64,
|
||||
operationId: String? = nil
|
||||
) {
|
||||
if let operationId,
|
||||
let current = loadLease(owner: owner, studentIdNorm: studentIdNorm),
|
||||
current.operationId != operationId {
|
||||
return
|
||||
}
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: keyAccount(owner: owner, studentIdNorm: studentIdNorm),
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
func clearLeases(studentIdNorm: Int64) {
|
||||
releaseLease(owner: .iphone, studentIdNorm: studentIdNorm, operationId: nil)
|
||||
releaseLease(owner: .watch, studentIdNorm: studentIdNorm, operationId: nil)
|
||||
}
|
||||
|
||||
func clearAllLeases() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status != errSecSuccess {
|
||||
return
|
||||
}
|
||||
|
||||
guard let items = result as? [[String: Any]] else {
|
||||
return
|
||||
}
|
||||
|
||||
for item in items {
|
||||
guard let account = item[kSecAttrAccount as String] as? String else {
|
||||
continue
|
||||
}
|
||||
let deleteQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: accessGroup,
|
||||
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
|
||||
]
|
||||
SecItemDelete(deleteQuery as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPeerLeaseRelease(
|
||||
owner: RefreshLeaseOwner,
|
||||
studentIdNorm: Int64,
|
||||
maxWaitMs: Int64,
|
||||
pollIntervalMs: Int64
|
||||
) async -> RefreshLeaseWaitResult {
|
||||
let startedAt = nowMs()
|
||||
var deadline = startedAt + max(maxWaitMs, 1_000)
|
||||
var lastFingerprint: String?
|
||||
var leaseChanged = false
|
||||
|
||||
while nowMs() < deadline {
|
||||
let now = nowMs()
|
||||
guard let peer = loadLease(owner: owner.peer, studentIdNorm: studentIdNorm) else {
|
||||
return RefreshLeaseWaitResult(
|
||||
ready: true,
|
||||
status: leaseChanged ? "peer_lease_changed" : "peer_lease_missing",
|
||||
waitedMs: now - startedAt,
|
||||
peerOperationId: nil,
|
||||
leaseChanged: leaseChanged
|
||||
)
|
||||
}
|
||||
|
||||
if peer.expiresAtMs <= now {
|
||||
releaseLease(
|
||||
owner: owner.peer,
|
||||
studentIdNorm: studentIdNorm,
|
||||
operationId: peer.operationId
|
||||
)
|
||||
return RefreshLeaseWaitResult(
|
||||
ready: true,
|
||||
status: "peer_lease_expired",
|
||||
waitedMs: now - startedAt,
|
||||
peerOperationId: peer.operationId,
|
||||
leaseChanged: leaseChanged
|
||||
)
|
||||
}
|
||||
|
||||
let fingerprint = "\(peer.operationId)|\(peer.startedAtMs)|\(peer.expiresAtMs)"
|
||||
if let previousFingerprint = lastFingerprint, previousFingerprint != fingerprint {
|
||||
leaseChanged = true
|
||||
deadline = min(deadline, peer.expiresAtMs + 5_000)
|
||||
lastFingerprint = fingerprint
|
||||
continue
|
||||
}
|
||||
|
||||
lastFingerprint = fingerprint
|
||||
deadline = min(deadline, peer.expiresAtMs + 5_000)
|
||||
let sleepMs = max(min(pollIntervalMs, 1_000), 50)
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepMs) * 1_000_000)
|
||||
}
|
||||
|
||||
let waited = max(nowMs() - startedAt, 0)
|
||||
let peerOperation = loadLease(owner: owner.peer, studentIdNorm: studentIdNorm)?.operationId
|
||||
return RefreshLeaseWaitResult(
|
||||
ready: false,
|
||||
status: "timed_out",
|
||||
waitedMs: waited,
|
||||
peerOperationId: peerOperation,
|
||||
leaseChanged: leaseChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ enum TokenError: Error {
|
||||
class TokenManager {
|
||||
static let shared = TokenManager()
|
||||
|
||||
private let appGroupID = "group.app.firka.firkaa"
|
||||
private let appGroupID = "group.app.firka.firka"
|
||||
private let tokenFileName = "watch_token.json"
|
||||
|
||||
private static let keychainService = "app.firka.watch.token"
|
||||
@@ -43,6 +43,14 @@ class TokenManager {
|
||||
private let activeStudentIdNormKey = "firka.active_student_id_norm"
|
||||
private let proactiveRefreshLeadTime: TimeInterval = 5 * 60
|
||||
private let minimumProactiveRefreshInterval: TimeInterval = 60
|
||||
private let iCloudProbeTimeoutNs: UInt64 = 1_500_000_000
|
||||
private let refreshRequestTimeout: TimeInterval = 12
|
||||
private let refreshResourceTimeout: TimeInterval = 20
|
||||
#if os(watchOS)
|
||||
private let watchRefreshLeaseTtlMs: Int64 = 180_000
|
||||
private let iPhoneRefreshLeaseMaxWaitMs: Int64 = 150_000
|
||||
private let refreshLeasePollIntervalMs: Int64 = 250
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
private let deviceName = "iPhone"
|
||||
@@ -52,6 +60,7 @@ class TokenManager {
|
||||
private let recoveryLock = NSLock()
|
||||
private var recoveryInProgress = false
|
||||
private var lastProactiveRefreshAttemptAt: Date?
|
||||
private(set) var lastRecoveryFailure: TokenError?
|
||||
#if os(watchOS)
|
||||
private var lastPhoneRecoveryRequestAt: Date?
|
||||
#endif
|
||||
@@ -78,6 +87,45 @@ class TokenManager {
|
||||
return recoveryInProgress
|
||||
}
|
||||
|
||||
func clearLastRecoveryFailure() {
|
||||
lastRecoveryFailure = nil
|
||||
}
|
||||
|
||||
#if os(watchOS)
|
||||
private func withWatchRefreshLease<T>(
|
||||
studentIdNorm: Int64,
|
||||
_ operation: () async throws -> T
|
||||
) async throws -> T {
|
||||
let waitResult = await RefreshLeaseManager.shared.waitForPeerLeaseRelease(
|
||||
owner: .watch,
|
||||
studentIdNorm: studentIdNorm,
|
||||
maxWaitMs: iPhoneRefreshLeaseMaxWaitMs,
|
||||
pollIntervalMs: refreshLeasePollIntervalMs
|
||||
)
|
||||
|
||||
guard waitResult.ready else {
|
||||
print("[TokenManager] Watch refresh lease wait timed out (waited \(waitResult.waitedMs)ms, changed: \(waitResult.leaseChanged))")
|
||||
throw TokenError.networkError
|
||||
}
|
||||
|
||||
let lease = RefreshLeaseManager.shared.acquireLease(
|
||||
owner: .watch,
|
||||
studentIdNorm: studentIdNorm,
|
||||
ttlMs: watchRefreshLeaseTtlMs
|
||||
)
|
||||
|
||||
defer {
|
||||
RefreshLeaseManager.shared.releaseLease(
|
||||
owner: .watch,
|
||||
studentIdNorm: studentIdNorm,
|
||||
operationId: lease.operationId
|
||||
)
|
||||
}
|
||||
|
||||
return try await operation()
|
||||
}
|
||||
#endif
|
||||
|
||||
private func getActiveStudentIdNorm() -> Int64? {
|
||||
if let value = UserDefaults.standard.object(forKey: activeStudentIdNormKey) as? Int64 {
|
||||
return value
|
||||
@@ -120,63 +168,52 @@ class TokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
iCloudTokenManager.shared.observeChanges { [weak self] iCloudToken in
|
||||
guard let self = self else { return }
|
||||
|
||||
let preferredStudentIdNorm = self.getActiveStudentIdNorm()
|
||||
let isValidToken = iCloudToken.expiryDate > Date().addingTimeInterval(60)
|
||||
let preferredLocalToken = self.localTokenFromKeychainAndFile(
|
||||
preferredStudentIdNorm: preferredStudentIdNorm
|
||||
)
|
||||
|
||||
if let preferredStudentIdNorm,
|
||||
iCloudToken.studentIdNorm != preferredStudentIdNorm,
|
||||
preferredLocalToken != nil {
|
||||
print("[TokenManager] Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
|
||||
return
|
||||
private func probeSharedKeychainTokenWithTimeout() async -> WatchToken? {
|
||||
await withTaskGroup(of: WatchToken?.self) { group in
|
||||
group.addTask {
|
||||
SharedKeychainManager.shared.loadToken()
|
||||
}
|
||||
group.addTask { [iCloudProbeTimeoutNs] in
|
||||
try? await Task.sleep(nanoseconds: iCloudProbeTimeoutNs)
|
||||
return nil
|
||||
}
|
||||
|
||||
let localToken = preferredLocalToken ?? self.localTokenFromKeychainAndFile()
|
||||
|
||||
if let localToken = localToken {
|
||||
if iCloudToken.isNewer(than: localToken) {
|
||||
print("[TokenManager] iCloud token is fresher, updating local cache")
|
||||
try? self.saveTokenToKeychain(iCloudToken)
|
||||
try? self.saveTokenToFile(iCloudToken)
|
||||
self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
|
||||
|
||||
#if os(watchOS)
|
||||
DataStore.shared.checkTokenState()
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if isValidToken {
|
||||
self.notifyiOSTokenRecovered()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
print("[TokenManager] Local token is fresher or equal, ignoring iCloud update")
|
||||
}
|
||||
} else {
|
||||
print("[TokenManager] No local token, using iCloud token")
|
||||
try? self.saveTokenToKeychain(iCloudToken)
|
||||
try? self.saveTokenToFile(iCloudToken)
|
||||
self.setActiveStudentIdNorm(iCloudToken.studentIdNorm)
|
||||
|
||||
#if os(watchOS)
|
||||
DataStore.shared.checkTokenState()
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if isValidToken {
|
||||
self.notifyiOSTokenRecovered()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
let first = await group.next() ?? nil
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
runKVStoreMigrationIfNeeded()
|
||||
}
|
||||
|
||||
private let kvStoreMigrationKey = "firka_kv_store_migrated_v1"
|
||||
|
||||
private func runKVStoreMigrationIfNeeded() {
|
||||
let alreadyMigrated = UserDefaults.standard.bool(forKey: kvStoreMigrationKey)
|
||||
if alreadyMigrated {
|
||||
return
|
||||
}
|
||||
|
||||
print("[TokenManager] Running KV Store migration...")
|
||||
|
||||
if let migratedToken = SharedKeychainManager.shared.migrateFromKVStoreAndClear() {
|
||||
SharedKeychainManager.shared.saveToken(migratedToken)
|
||||
|
||||
try? saveTokenToKeychain(migratedToken)
|
||||
try? saveTokenToFile(migratedToken)
|
||||
setActiveStudentIdNorm(migratedToken.studentIdNorm)
|
||||
|
||||
print("[TokenManager] KV Store migration completed, token migrated")
|
||||
} else {
|
||||
SharedKeychainManager.shared.clearKVStore()
|
||||
print("[TokenManager] KV Store migration completed, no token to migrate")
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(true, forKey: kvStoreMigrationKey)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func notifyiOSTokenRecovered() {
|
||||
print("[TokenManager] Valid token received from iCloud, notifying Flutter to clear reauth flag")
|
||||
@@ -199,12 +236,12 @@ class TokenManager {
|
||||
|
||||
// MARK: - Load Token (active-account first)
|
||||
func loadToken() -> WatchToken? {
|
||||
let iCloudToken = iCloudTokenManager.shared.loadToken()
|
||||
let sharedKeychainToken = SharedKeychainManager.shared.loadToken()
|
||||
let keychainToken = loadTokenFromKeychain()
|
||||
let fileToken = loadTokenFromFile()
|
||||
|
||||
var candidates: [(token: WatchToken, source: String)] = []
|
||||
if let t = iCloudToken { candidates.append((t, "iCloud")) }
|
||||
if let t = sharedKeychainToken { candidates.append((t, "sharedKeychain")) }
|
||||
if let t = keychainToken { candidates.append((t, "keychain")) }
|
||||
if let t = fileToken { candidates.append((t, "file")) }
|
||||
|
||||
@@ -213,7 +250,24 @@ class TokenManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
let preferredStudentIdNorm = getActiveStudentIdNorm()
|
||||
var preferredStudentIdNorm = getActiveStudentIdNorm()
|
||||
var requirePreferredAccount = false
|
||||
#if os(watchOS)
|
||||
if let sessionState = SharedSessionStateManager.shared.loadState() {
|
||||
if !sessionState.hasAnyAccount {
|
||||
print("[TokenManager] Shared session state indicates no active accounts, returning no token")
|
||||
return nil
|
||||
}
|
||||
if let sharedActiveStudentIdNorm = sessionState.activeStudentIdNorm {
|
||||
preferredStudentIdNorm = sharedActiveStudentIdNorm
|
||||
requirePreferredAccount = true
|
||||
if getActiveStudentIdNorm() != sharedActiveStudentIdNorm {
|
||||
setActiveStudentIdNorm(sharedActiveStudentIdNorm)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
let freshest: (token: WatchToken, source: String)
|
||||
if let preferredStudentIdNorm {
|
||||
let filtered = candidates.filter { $0.token.studentIdNorm == preferredStudentIdNorm }
|
||||
@@ -223,7 +277,15 @@ class TokenManager {
|
||||
} {
|
||||
freshest = preferredFreshest
|
||||
} else {
|
||||
print("[TokenManager] Active account token not found locally, falling back to freshest available account")
|
||||
if requirePreferredAccount {
|
||||
print("[TokenManager] Active shared-session account token (\(preferredStudentIdNorm)) not found yet, falling back to best available token")
|
||||
#if os(watchOS)
|
||||
if WCSession.default.activationState == .activated && WCSession.default.isReachable {
|
||||
print("[TokenManager] iPhone reachable, requesting active account token")
|
||||
WatchConnectivityManager.shared.requestTokenFromPhone()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
freshest = candidates.dropFirst().reduce(candidates[0]) { currentBest, candidate in
|
||||
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
|
||||
}
|
||||
@@ -233,8 +295,19 @@ class TokenManager {
|
||||
candidate.token.isNewer(than: currentBest.token) ? candidate : currentBest
|
||||
}
|
||||
}
|
||||
let previousActiveStudentIdNorm = getActiveStudentIdNorm()
|
||||
setActiveStudentIdNorm(freshest.token.studentIdNorm)
|
||||
|
||||
#if os(iOS)
|
||||
if previousActiveStudentIdNorm != freshest.token.studentIdNorm {
|
||||
_ = SharedSessionStateManager.shared.publishState(
|
||||
hasAnyAccount: true,
|
||||
activeStudentIdNorm: freshest.token.studentIdNorm
|
||||
)
|
||||
print("[TokenManager] Active account changed from \(previousActiveStudentIdNorm ?? 0) to \(freshest.token.studentIdNorm), published to SharedSessionState")
|
||||
}
|
||||
#endif
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
@@ -276,8 +349,16 @@ class TokenManager {
|
||||
// MARK: - Delete Token
|
||||
func deleteToken() {
|
||||
print("[TokenManager] Deleting token from all storage locations")
|
||||
|
||||
SharedSessionStateManager.shared.publishState(hasAnyAccount: false, activeStudentIdNorm: nil)
|
||||
|
||||
if let previousToken = loadToken() {
|
||||
RefreshLeaseManager.shared.clearLeases(studentIdNorm: previousToken.studentIdNorm)
|
||||
} else {
|
||||
RefreshLeaseManager.shared.clearAllLeases()
|
||||
}
|
||||
deleteTokenFromKeychain()
|
||||
iCloudTokenManager.shared.deleteToken()
|
||||
SharedKeychainManager.shared.deleteToken()
|
||||
UserDefaults.standard.removeObject(forKey: activeStudentIdNormKey)
|
||||
|
||||
guard let filePath = getTokenFilePath() else { return }
|
||||
@@ -287,10 +368,11 @@ class TokenManager {
|
||||
// MARK: - Save Token
|
||||
func saveToken(
|
||||
_ token: WatchToken,
|
||||
syncToICloud: Bool = false,
|
||||
syncToSharedKeychain: Bool = false,
|
||||
forceAccountSwitch: Bool = false
|
||||
) throws {
|
||||
if let currentToken = loadToken() {
|
||||
let currentToken = loadToken()
|
||||
if let currentToken {
|
||||
if forceAccountSwitch && !token.isSameAccount(as: currentToken) {
|
||||
print("[TokenManager] Forcing token save for explicit account switch (\(currentToken.studentIdNorm) -> \(token.studentIdNorm))")
|
||||
} else if !token.isNewer(than: currentToken) {
|
||||
@@ -299,13 +381,19 @@ class TokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
if forceAccountSwitch,
|
||||
let currentToken,
|
||||
!token.isSameAccount(as: currentToken) {
|
||||
RefreshLeaseManager.shared.clearLeases(studentIdNorm: currentToken.studentIdNorm)
|
||||
}
|
||||
|
||||
print("[TokenManager] Saving token locally (Keychain + file)")
|
||||
setActiveStudentIdNorm(token.studentIdNorm)
|
||||
|
||||
try saveTokenToKeychain(token)
|
||||
|
||||
if syncToICloud {
|
||||
iCloudTokenManager.shared.saveToken(token, deviceName: deviceName)
|
||||
if syncToSharedKeychain {
|
||||
SharedKeychainManager.shared.saveToken(token, forceAccountSwitch: forceAccountSwitch)
|
||||
}
|
||||
|
||||
guard let filePath = getTokenFilePath() else {
|
||||
@@ -444,16 +532,25 @@ class TokenManager {
|
||||
return
|
||||
}
|
||||
_ = try await refreshTokenInternal(token)
|
||||
clearLastRecoveryFailure()
|
||||
print("[TokenManager] Proactive token refresh succeeded")
|
||||
} catch {
|
||||
if let tokenError = error as? TokenError {
|
||||
lastRecoveryFailure = tokenError
|
||||
} else {
|
||||
lastRecoveryFailure = .networkError
|
||||
}
|
||||
print("[TokenManager] Proactive token refresh failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Central Token Recovery
|
||||
func recoverToken() async -> WatchToken? {
|
||||
clearLastRecoveryFailure()
|
||||
|
||||
if let validToken = loadToken(), !isTokenExpired() {
|
||||
print("[TokenManager] Existing token is valid, skipping recovery flow")
|
||||
clearLastRecoveryFailure()
|
||||
return validToken
|
||||
}
|
||||
|
||||
@@ -484,18 +581,49 @@ class TokenManager {
|
||||
|
||||
print("[TokenManager] Starting central token recovery...")
|
||||
|
||||
if let sharedToken = await probeSharedKeychainTokenWithTimeout() {
|
||||
let now = Date()
|
||||
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
|
||||
sharedToken.studentIdNorm != preferredStudentIdNorm,
|
||||
localTokenFromKeychainAndFile(preferredStudentIdNorm: preferredStudentIdNorm) != nil {
|
||||
print("[TokenManager] Shared Keychain probe token belongs to inactive account, skipping direct apply")
|
||||
} else if sharedToken.expiryDate > now.addingTimeInterval(60) {
|
||||
print("[TokenManager] Shared Keychain probe found valid token, applying without recovery")
|
||||
do {
|
||||
try saveToken(sharedToken, syncToSharedKeychain: false)
|
||||
clearLastRecoveryFailure()
|
||||
return sharedToken
|
||||
} catch {
|
||||
print("[TokenManager] Failed to apply shared Keychain probe token: \(error)")
|
||||
}
|
||||
} else {
|
||||
print("[TokenManager] Shared Keychain probe token exists but access is expired, continuing with refresh path")
|
||||
}
|
||||
} else {
|
||||
print("[TokenManager] Shared Keychain probe timed out or no token available, continuing with refresh path")
|
||||
}
|
||||
|
||||
print("[TokenManager] Step 1: Trying local token refresh...")
|
||||
if let token = loadToken() {
|
||||
if token.expiryDate > Date().addingTimeInterval(60) {
|
||||
print("[TokenManager] Step 1 SUCCESS: Local token already valid")
|
||||
clearLastRecoveryFailure()
|
||||
return token
|
||||
}
|
||||
do {
|
||||
let refreshedToken = try await refreshTokenInternal(token)
|
||||
print("[TokenManager] Step 1 SUCCESS: Local refresh succeeded")
|
||||
clearLastRecoveryFailure()
|
||||
return refreshedToken
|
||||
} catch {
|
||||
print("[TokenManager] Step 1 FAILED: Local refresh failed: \(error)")
|
||||
if let tokenError = error as? TokenError {
|
||||
lastRecoveryFailure = tokenError
|
||||
if tokenError == .networkError {
|
||||
print("[TokenManager] Step 1 detected network error, aborting recovery flow")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("[TokenManager] Step 1 SKIPPED: No local token found")
|
||||
@@ -505,72 +633,93 @@ class TokenManager {
|
||||
if let recoveredToken = await tryRecoverFromKeychainAndWatch() {
|
||||
if recoveredToken.expiryDate > Date().addingTimeInterval(60) {
|
||||
print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token is already valid")
|
||||
try? saveToken(recoveredToken, syncToICloud: false)
|
||||
try? saveToken(recoveredToken, syncToSharedKeychain: false)
|
||||
clearLastRecoveryFailure()
|
||||
return recoveredToken
|
||||
} else {
|
||||
do {
|
||||
let refreshedToken = try await refreshTokenInternal(recoveredToken)
|
||||
print("[TokenManager] Step 2 SUCCESS: Keychain/Watch token refresh succeeded")
|
||||
clearLastRecoveryFailure()
|
||||
return refreshedToken
|
||||
} catch {
|
||||
print("[TokenManager] Step 2 FAILED: Keychain/Watch token refresh failed: \(error)")
|
||||
if let tokenError = error as? TokenError {
|
||||
lastRecoveryFailure = tokenError
|
||||
if tokenError == .networkError {
|
||||
print("[TokenManager] Step 2 detected network error, aborting recovery flow")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("[TokenManager] Step 2 SKIPPED: No token from Keychain/Watch")
|
||||
}
|
||||
|
||||
print("[TokenManager] Step 3: Trying iCloud recovery with retries...")
|
||||
print("[TokenManager] Step 3: Trying shared Keychain recovery with retries...")
|
||||
let retryDelays: [TimeInterval] = [0, 5, 10, 5, 10]
|
||||
var iCloudHasToken = false
|
||||
var sharedKeychainHasToken = false
|
||||
|
||||
for (attempt, delay) in retryDelays.enumerated() {
|
||||
if delay > 0 {
|
||||
if !iCloudHasToken && attempt > 0 {
|
||||
print("[TokenManager] Step 3: Skipping retries - iCloud has no token")
|
||||
if !sharedKeychainHasToken && attempt > 0 {
|
||||
print("[TokenManager] Step 3: Skipping retries - shared Keychain has no token")
|
||||
break
|
||||
}
|
||||
print("[TokenManager] Step 3: Waiting \(Int(delay))s before attempt \(attempt + 1)...")
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
}
|
||||
|
||||
print("[TokenManager] Step 3: iCloud attempt \(attempt + 1)/\(retryDelays.count)...")
|
||||
print("[TokenManager] Step 3: Shared Keychain attempt \(attempt + 1)/\(retryDelays.count)...")
|
||||
|
||||
if let iCloudToken = iCloudTokenManager.shared.loadToken() {
|
||||
if let sharedToken = SharedKeychainManager.shared.loadToken() {
|
||||
if let preferredStudentIdNorm = getActiveStudentIdNorm(),
|
||||
iCloudToken.studentIdNorm != preferredStudentIdNorm {
|
||||
sharedToken.studentIdNorm != preferredStudentIdNorm {
|
||||
if localTokenFromKeychainAndFile(
|
||||
preferredStudentIdNorm: preferredStudentIdNorm
|
||||
) != nil {
|
||||
print("[TokenManager] Step 3: Ignoring iCloud token for inactive account (\(iCloudToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
|
||||
print("[TokenManager] Step 3: Ignoring shared Keychain token for inactive account (\(sharedToken.studentIdNorm)), active is \(preferredStudentIdNorm)")
|
||||
continue
|
||||
}
|
||||
print("[TokenManager] Step 3: Active account token missing locally, considering different-account iCloud token")
|
||||
print("[TokenManager] Step 3: Active account token missing locally, considering different-account shared Keychain token")
|
||||
}
|
||||
iCloudHasToken = true
|
||||
if iCloudToken.expiryDate > Date() {
|
||||
print("[TokenManager] Step 3 SUCCESS: Found valid iCloud token, applying without immediate refresh")
|
||||
try? saveToken(iCloudToken, syncToICloud: false)
|
||||
return iCloudToken
|
||||
sharedKeychainHasToken = true
|
||||
if sharedToken.expiryDate > Date() {
|
||||
print("[TokenManager] Step 3 SUCCESS: Found valid shared Keychain token, applying without immediate refresh")
|
||||
try? saveToken(sharedToken, syncToSharedKeychain: false)
|
||||
clearLastRecoveryFailure()
|
||||
return sharedToken
|
||||
} else {
|
||||
print("[TokenManager] Step 3: iCloud token is expired, trying refresh anyway...")
|
||||
print("[TokenManager] Step 3: Shared Keychain token is expired, trying refresh anyway...")
|
||||
do {
|
||||
let refreshedToken = try await refreshTokenInternal(iCloudToken)
|
||||
print("[TokenManager] Step 3 SUCCESS: Expired iCloud token refresh succeeded on attempt \(attempt + 1)")
|
||||
let refreshedToken = try await refreshTokenInternal(sharedToken)
|
||||
print("[TokenManager] Step 3 SUCCESS: Expired shared Keychain token refresh succeeded on attempt \(attempt + 1)")
|
||||
clearLastRecoveryFailure()
|
||||
return refreshedToken
|
||||
} catch {
|
||||
print("[TokenManager] Step 3: Expired iCloud token refresh failed on attempt \(attempt + 1): \(error)")
|
||||
print("[TokenManager] Step 3: Expired shared Keychain token refresh failed on attempt \(attempt + 1): \(error)")
|
||||
if let tokenError = error as? TokenError {
|
||||
lastRecoveryFailure = tokenError
|
||||
if tokenError == .networkError {
|
||||
print("[TokenManager] Step 3 detected network error, aborting retries")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("[TokenManager] Step 3: No token in iCloud on attempt \(attempt + 1)")
|
||||
print("[TokenManager] Step 3: No token in shared Keychain on attempt \(attempt + 1)")
|
||||
if attempt == 0 {
|
||||
iCloudHasToken = false
|
||||
sharedKeychainHasToken = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("[TokenManager] All recovery attempts failed")
|
||||
if lastRecoveryFailure == nil {
|
||||
lastRecoveryFailure = .noToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -698,6 +847,32 @@ class TokenManager {
|
||||
#endif
|
||||
|
||||
private func refreshTokenInternal(_ token: WatchToken) async throws -> WatchToken {
|
||||
#if os(watchOS)
|
||||
return try await withWatchRefreshLease(studentIdNorm: token.studentIdNorm) {
|
||||
let response = try await performTokenRefresh(
|
||||
refreshToken: token.refreshToken,
|
||||
instituteCode: token.iss
|
||||
)
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let tokenVersion = WatchToken.extractIatMillis(from: response.idToken) ?? nowMs
|
||||
|
||||
let newToken = WatchToken(
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
idToken: response.idToken,
|
||||
iss: token.iss,
|
||||
studentId: token.studentId,
|
||||
studentIdNorm: token.studentIdNorm,
|
||||
expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60),
|
||||
tokenVersion: tokenVersion,
|
||||
updatedAtMs: nowMs
|
||||
)
|
||||
|
||||
try saveToken(newToken, syncToSharedKeychain: true)
|
||||
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
|
||||
return newToken
|
||||
}
|
||||
#else
|
||||
let response = try await performTokenRefresh(
|
||||
refreshToken: token.refreshToken,
|
||||
instituteCode: token.iss
|
||||
@@ -717,13 +892,9 @@ class TokenManager {
|
||||
updatedAtMs: nowMs
|
||||
)
|
||||
|
||||
try saveToken(newToken, syncToICloud: true)
|
||||
|
||||
#if os(watchOS)
|
||||
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
|
||||
#endif
|
||||
|
||||
try saveToken(newToken, syncToSharedKeychain: true)
|
||||
return newToken
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Refresh Token
|
||||
@@ -732,6 +903,32 @@ class TokenManager {
|
||||
throw TokenError.noToken
|
||||
}
|
||||
|
||||
#if os(watchOS)
|
||||
return try await withWatchRefreshLease(studentIdNorm: currentToken.studentIdNorm) {
|
||||
let response = try await performTokenRefresh(
|
||||
refreshToken: currentToken.refreshToken,
|
||||
instituteCode: currentToken.iss
|
||||
)
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let tokenVersion = WatchToken.extractIatMillis(from: response.idToken) ?? nowMs
|
||||
|
||||
let newToken = WatchToken(
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
idToken: response.idToken,
|
||||
iss: currentToken.iss,
|
||||
studentId: currentToken.studentId,
|
||||
studentIdNorm: currentToken.studentIdNorm,
|
||||
expiryDate: Date().addingTimeInterval(Double(response.expiresIn) - 60),
|
||||
tokenVersion: tokenVersion,
|
||||
updatedAtMs: nowMs
|
||||
)
|
||||
|
||||
try saveToken(newToken, syncToSharedKeychain: true)
|
||||
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
|
||||
return newToken
|
||||
}
|
||||
#else
|
||||
let response = try await performTokenRefresh(
|
||||
refreshToken: currentToken.refreshToken,
|
||||
instituteCode: currentToken.iss
|
||||
@@ -751,13 +948,9 @@ class TokenManager {
|
||||
updatedAtMs: nowMs
|
||||
)
|
||||
|
||||
try saveToken(newToken, syncToICloud: true)
|
||||
|
||||
#if os(watchOS)
|
||||
WatchConnectivityManager.shared.sendTokenToiPhoneInBackground()
|
||||
#endif
|
||||
|
||||
try saveToken(newToken, syncToSharedKeychain: true)
|
||||
return newToken
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Private Helper Methods
|
||||
@@ -774,6 +967,7 @@ class TokenManager {
|
||||
request.setValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||
request.setValue("*/*", forHTTPHeaderField: "Accept")
|
||||
request.timeoutInterval = refreshRequestTimeout
|
||||
|
||||
let formParameters: [String: String] = [
|
||||
"institute_code": instituteCode,
|
||||
@@ -785,7 +979,11 @@ class TokenManager {
|
||||
request.httpBody = encodeFormData(formParameters).data(using: .utf8)
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = refreshRequestTimeout
|
||||
configuration.timeoutIntervalForResource = refreshResourceTimeout
|
||||
let session = URLSession(configuration: configuration)
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw TokenError.networkError
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
class iCloudTokenManager {
|
||||
static let shared = iCloudTokenManager()
|
||||
|
||||
private let iCloudStore = NSUbiquitousKeyValueStore.default
|
||||
|
||||
private let kAccessToken = "firka_access_token"
|
||||
private let kRefreshToken = "firka_refresh_token"
|
||||
private let kIdToken = "firka_id_token"
|
||||
private let kIss = "firka_iss"
|
||||
private let kStudentId = "firka_student_id"
|
||||
private let kStudentIdNorm = "firka_student_id_norm"
|
||||
private let kExpiryDate = "firka_expiry_date"
|
||||
private let kTokenVersion = "firka_token_version"
|
||||
private let kUpdatedAtMs = "firka_updated_at_ms"
|
||||
private let kLastUpdatedDevice = "firka_last_updated_device"
|
||||
private let kLastUpdateTimestamp = "firka_last_update_timestamp"
|
||||
|
||||
private var changeObserver: ((WatchToken) -> Void)?
|
||||
private var isAvailable = false
|
||||
|
||||
private init() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(iCloudStoreDidChange(_:)),
|
||||
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: iCloudStore
|
||||
)
|
||||
|
||||
isAvailable = iCloudStore.synchronize()
|
||||
if isAvailable {
|
||||
print("[iCloud] iCloud KeyValue Store available and synced")
|
||||
} else {
|
||||
print("[iCloud] iCloud not available (not signed in or disabled) - using local storage only")
|
||||
}
|
||||
}
|
||||
|
||||
func saveToken(_ token: WatchToken, deviceName: String) {
|
||||
guard isAvailable else {
|
||||
return
|
||||
}
|
||||
|
||||
if let existingToken = loadToken() {
|
||||
if existingToken.isSameAccount(as: token) {
|
||||
if !token.isNewer(than: existingToken) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
print("[iCloud] Ignoring stale token save from \(deviceName), existing expiry: \(formatter.string(from: existingToken.expiryDate)), incoming: \(formatter.string(from: token.expiryDate))")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
let incomingUpdatedAt = token.effectiveUpdatedAtMs ?? 0
|
||||
let existingUpdatedAt = existingToken.effectiveUpdatedAtMs ?? 0
|
||||
if incomingUpdatedAt > 0 &&
|
||||
existingUpdatedAt > 0 &&
|
||||
incomingUpdatedAt <= existingUpdatedAt {
|
||||
print("[iCloud] Ignoring cross-account stale token save from \(deviceName)")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("[iCloud] Saving token to iCloud from \(deviceName)")
|
||||
|
||||
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let updatedAtMs = token.effectiveUpdatedAtMs ?? nowMs
|
||||
let tokenVersion = token.effectiveTokenVersion
|
||||
|
||||
iCloudStore.set(token.accessToken, forKey: kAccessToken)
|
||||
iCloudStore.set(token.refreshToken, forKey: kRefreshToken)
|
||||
iCloudStore.set(token.idToken, forKey: kIdToken)
|
||||
iCloudStore.set(token.iss, forKey: kIss)
|
||||
iCloudStore.set(token.studentId, forKey: kStudentId)
|
||||
iCloudStore.set(token.studentIdNorm, forKey: kStudentIdNorm)
|
||||
iCloudStore.set(token.expiryDate.timeIntervalSince1970, forKey: kExpiryDate)
|
||||
if let tokenVersion {
|
||||
iCloudStore.set(tokenVersion, forKey: kTokenVersion)
|
||||
} else {
|
||||
iCloudStore.removeObject(forKey: kTokenVersion)
|
||||
}
|
||||
iCloudStore.set(updatedAtMs, forKey: kUpdatedAtMs)
|
||||
iCloudStore.set(deviceName, forKey: kLastUpdatedDevice)
|
||||
iCloudStore.set(Double(updatedAtMs) / 1000.0, forKey: kLastUpdateTimestamp)
|
||||
|
||||
let success = iCloudStore.synchronize()
|
||||
if success {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
print("[iCloud] Token saved successfully, expiry: \(formatter.string(from: token.expiryDate))")
|
||||
} else {
|
||||
print("[iCloud] Failed to synchronize token to iCloud")
|
||||
}
|
||||
}
|
||||
|
||||
func loadToken() -> WatchToken? {
|
||||
guard isAvailable else {
|
||||
return nil
|
||||
}
|
||||
|
||||
iCloudStore.synchronize()
|
||||
|
||||
guard let accessToken = iCloudStore.string(forKey: kAccessToken),
|
||||
let refreshToken = iCloudStore.string(forKey: kRefreshToken),
|
||||
let idToken = iCloudStore.string(forKey: kIdToken),
|
||||
let iss = iCloudStore.string(forKey: kIss),
|
||||
let studentId = iCloudStore.string(forKey: kStudentId) else {
|
||||
print("[iCloud] No token found in iCloud")
|
||||
return nil
|
||||
}
|
||||
|
||||
let studentIdNorm = iCloudStore.longLong(forKey: kStudentIdNorm)
|
||||
let expiryTimestamp = iCloudStore.double(forKey: kExpiryDate)
|
||||
let tokenVersionRaw = iCloudStore.longLong(forKey: kTokenVersion)
|
||||
let updatedAtMsRaw = iCloudStore.longLong(forKey: kUpdatedAtMs)
|
||||
let fallbackUpdatedAt = Int64(iCloudStore.double(forKey: kLastUpdateTimestamp) * 1000.0)
|
||||
let lastDevice = iCloudStore.string(forKey: kLastUpdatedDevice) ?? "unknown"
|
||||
|
||||
guard expiryTimestamp > 0 else {
|
||||
print("[iCloud] Invalid expiry date in iCloud")
|
||||
return nil
|
||||
}
|
||||
|
||||
let expiryDate = Date(timeIntervalSince1970: expiryTimestamp)
|
||||
|
||||
let token = WatchToken(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
idToken: idToken,
|
||||
iss: iss,
|
||||
studentId: studentId,
|
||||
studentIdNorm: studentIdNorm,
|
||||
expiryDate: expiryDate,
|
||||
tokenVersion: tokenVersionRaw > 0 ? tokenVersionRaw : nil,
|
||||
updatedAtMs: updatedAtMsRaw > 0 ? updatedAtMsRaw : (fallbackUpdatedAt > 0 ? fallbackUpdatedAt : nil)
|
||||
)
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current
|
||||
print("[iCloud] Token loaded from iCloud (last updated by: \(lastDevice)), expiry: \(formatter.string(from: expiryDate))")
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
func deleteToken() {
|
||||
guard isAvailable else {
|
||||
return
|
||||
}
|
||||
|
||||
print("[iCloud] Deleting token from iCloud")
|
||||
|
||||
iCloudStore.removeObject(forKey: kAccessToken)
|
||||
iCloudStore.removeObject(forKey: kRefreshToken)
|
||||
iCloudStore.removeObject(forKey: kIdToken)
|
||||
iCloudStore.removeObject(forKey: kIss)
|
||||
iCloudStore.removeObject(forKey: kStudentId)
|
||||
iCloudStore.removeObject(forKey: kStudentIdNorm)
|
||||
iCloudStore.removeObject(forKey: kExpiryDate)
|
||||
iCloudStore.removeObject(forKey: kTokenVersion)
|
||||
iCloudStore.removeObject(forKey: kUpdatedAtMs)
|
||||
iCloudStore.removeObject(forKey: kLastUpdatedDevice)
|
||||
iCloudStore.removeObject(forKey: kLastUpdateTimestamp)
|
||||
|
||||
iCloudStore.synchronize()
|
||||
}
|
||||
|
||||
func observeChanges(_ observer: @escaping (WatchToken) -> Void) {
|
||||
self.changeObserver = observer
|
||||
}
|
||||
|
||||
@objc private func iCloudStoreDidChange(_ notification: Notification) {
|
||||
guard let userInfo = notification.userInfo,
|
||||
let changeReason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else {
|
||||
return
|
||||
}
|
||||
|
||||
if changeReason == NSUbiquitousKeyValueStoreServerChange ||
|
||||
changeReason == NSUbiquitousKeyValueStoreInitialSyncChange {
|
||||
|
||||
print("[iCloud] Token changed externally in iCloud")
|
||||
|
||||
if let token = loadToken() {
|
||||
let lastDevice = iCloudStore.string(forKey: kLastUpdatedDevice) ?? "unknown"
|
||||
print("[iCloud] Received updated token from: \(lastDevice)")
|
||||
changeObserver?(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getLastUpdatedDevice() -> String? {
|
||||
guard isAvailable else {
|
||||
return nil
|
||||
}
|
||||
iCloudStore.synchronize()
|
||||
return iCloudStore.string(forKey: kLastUpdatedDevice)
|
||||
}
|
||||
|
||||
func getLastUpdateTimestamp() -> Date? {
|
||||
guard isAvailable else {
|
||||
return nil
|
||||
}
|
||||
iCloudStore.synchronize()
|
||||
let timestamp = iCloudStore.double(forKey: kLastUpdateTimestamp)
|
||||
guard timestamp > 0 else { return nil }
|
||||
return Date(timeIntervalSince1970: timestamp)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ struct WidgetData: Codable {
|
||||
|
||||
static func load() -> WidgetData? {
|
||||
guard let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: "group.app.firka.firkaa"
|
||||
forSecurityApplicationGroupIdentifier: "group.app.firka.firka"
|
||||
) else {
|
||||
lastError = "No App Group container"
|
||||
return nil
|
||||
|
||||
843
firka/lib/api/client/kreta_client.dart
Normal file
@@ -0,0 +1,843 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firka/core/extensions.dart';
|
||||
import 'package:firka/data/models/generic_cache_model.dart';
|
||||
import 'package:firka/data/models/timetable_cache_model.dart';
|
||||
import 'package:isar_community/isar.dart';
|
||||
import 'package:kreta_api/kreta_api.dart';
|
||||
|
||||
import 'package:firka/app/app_state.dart';
|
||||
import 'package:firka/core/bloc/reauth_cubit.dart';
|
||||
import 'package:firka/data/models/token_model.dart';
|
||||
import 'package:firka/core/debug_helper.dart';
|
||||
import 'package:firka/data/util.dart';
|
||||
import 'package:firka/services/active_account_helper.dart';
|
||||
import 'package:firka/services/watch_sync_helper.dart';
|
||||
import '../consts.dart';
|
||||
import '../token_grant.dart';
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
const _watchChannel = MethodChannel('app.firka/watch_sync');
|
||||
|
||||
const backoffCount = 4;
|
||||
const backoffMin = 100;
|
||||
const backoffStep = 500;
|
||||
|
||||
class KretaClient {
|
||||
Completer<void>? _tokenMutexCompleter;
|
||||
TokenModel model;
|
||||
Isar isar;
|
||||
final ReauthCubit _reauthCubit;
|
||||
|
||||
KretaClient(this.model, this.isar, this._reauthCubit);
|
||||
|
||||
bool get needsReauth => _reauthCubit.state.needsReauth;
|
||||
|
||||
void clearReauthFlag() {
|
||||
_reauthCubit.clear();
|
||||
debugPrint('[KretaClient] Reauth flag cleared');
|
||||
}
|
||||
|
||||
Future<void> _setReauthFlag() async {
|
||||
if (needsReauth) return;
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
_watchChannel.invokeMethod('notifyReauthRequired');
|
||||
} catch (e) {
|
||||
debugPrint('[KretaClient] Watch reauth notification skipped: $e');
|
||||
}
|
||||
}
|
||||
_reauthCubit.setNeedsReauth(true);
|
||||
debugPrint('[KretaClient] Reauth flag set');
|
||||
}
|
||||
|
||||
Future<TokenModel> _refreshModelWithCrossDeviceLease(
|
||||
TokenModel sourceToken,
|
||||
) async {
|
||||
final studentIdNorm = sourceToken.studentIdNorm;
|
||||
String? leaseOperationId;
|
||||
|
||||
try {
|
||||
if (Platform.isIOS && studentIdNorm != null) {
|
||||
final watchInstalled = await WatchSyncHelper.isWatchAppInstalled();
|
||||
if (watchInstalled) {
|
||||
final leaseReady = await WatchSyncHelper.waitForWatchRefreshLease(
|
||||
studentIdNorm: studentIdNorm,
|
||||
);
|
||||
if (!leaseReady) {
|
||||
throw Exception('watch_refresh_lease_timeout');
|
||||
}
|
||||
leaseOperationId = await WatchSyncHelper.acquireIPhoneRefreshLease(
|
||||
studentIdNorm: studentIdNorm,
|
||||
);
|
||||
if (leaseOperationId == null) {
|
||||
throw Exception('iphone_refresh_lease_acquire_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final extended = await extendToken(sourceToken);
|
||||
return TokenModel.fromResp(extended);
|
||||
} finally {
|
||||
if (Platform.isIOS && studentIdNorm != null && leaseOperationId != null) {
|
||||
await WatchSyncHelper.releaseIPhoneRefreshLease(
|
||||
studentIdNorm: studentIdNorm,
|
||||
operationId: leaseOperationId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncTokenToAppleTargets(TokenModel token) async {
|
||||
if (!Platform.isIOS) return;
|
||||
if (token.accessToken == null ||
|
||||
token.refreshToken == null ||
|
||||
token.expiryDate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final watchInstalled = await WatchSyncHelper.isWatchAppInstalled();
|
||||
if (!watchInstalled) {
|
||||
debugPrint(
|
||||
'[KretaClient] Skipping Apple token sync because no paired Watch app is installed',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await WatchSyncHelper.saveTokenToiCloud(token);
|
||||
} catch (e) {
|
||||
debugPrint('[KretaClient] iCloud token sync skipped: $e');
|
||||
}
|
||||
|
||||
try {
|
||||
await WatchSyncHelper.sendTokenToWatch();
|
||||
} catch (e) {
|
||||
debugPrint('[KretaClient] Watch token sync skipped: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reloadActiveTokenModel({int? preferredStudentIdNorm}) async {
|
||||
final allTokens = await isar.tokenModels.where().findAll();
|
||||
if (allTokens.isEmpty) return;
|
||||
|
||||
if (initDone) {
|
||||
initData.tokens = allTokens;
|
||||
final selected = pickActiveToken(
|
||||
tokens: allTokens,
|
||||
settings: initData.settings,
|
||||
preferredStudentIdNorm: preferredStudentIdNorm ?? model.studentIdNorm,
|
||||
);
|
||||
if (selected != null) {
|
||||
model = selected;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (preferredStudentIdNorm != null) {
|
||||
for (final token in allTokens) {
|
||||
if (token.studentIdNorm == preferredStudentIdNorm) {
|
||||
model = token;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
model = allTokens.first;
|
||||
}
|
||||
|
||||
Future<bool> recoverToken() async {
|
||||
logger.info("[Recovery] Starting central token recovery...");
|
||||
final now = timeNow();
|
||||
final localExpiry = model.expiryDate;
|
||||
if (localExpiry != null &&
|
||||
localExpiry.isAfter(now.add(const Duration(seconds: 60)))) {
|
||||
logger.info(
|
||||
"[Recovery] Existing token is still valid, skipping recovery steps",
|
||||
);
|
||||
clearReauthFlag();
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info("[Recovery] Step 1: Trying local token refresh...");
|
||||
try {
|
||||
var tokenModel = await _refreshModelWithCrossDeviceLease(model);
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.tokenModels.put(tokenModel);
|
||||
});
|
||||
|
||||
model = tokenModel;
|
||||
await _syncTokenToAppleTargets(model);
|
||||
clearReauthFlag();
|
||||
logger.info("[Recovery] Step 1 SUCCESS: Local refresh succeeded");
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warning("[Recovery] Step 1 FAILED: Local refresh failed: $e");
|
||||
}
|
||||
|
||||
if (!Platform.isIOS || !initDone) {
|
||||
logger.warning(
|
||||
"[Recovery] Not iOS or not initialized, cannot try iCloud",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info("[Recovery] Step 2: Trying iCloud recovery with retries...");
|
||||
const retryDelays = [0, 5, 10, 5, 10]; // instant, 5s, 10s, 5s, 10s
|
||||
bool iCloudHasToken =
|
||||
false; // Track if iCloud has any token (to avoid useless retries)
|
||||
|
||||
for (var attempt = 0; attempt < retryDelays.length; attempt++) {
|
||||
final delay = retryDelays[attempt];
|
||||
if (delay > 0) {
|
||||
if (!iCloudHasToken && attempt > 0) {
|
||||
logger.info("[Recovery] Skipping retries - iCloud has no token");
|
||||
break;
|
||||
}
|
||||
logger.info(
|
||||
"[Recovery] Waiting ${delay}s before attempt ${attempt + 1}...",
|
||||
);
|
||||
await Future.delayed(Duration(seconds: delay));
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[Recovery] iCloud attempt ${attempt + 1}/${retryDelays.length}...",
|
||||
);
|
||||
|
||||
final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud(
|
||||
isar: isar,
|
||||
tokens: initData.tokens,
|
||||
client: this,
|
||||
allowExpiredAccessToken: true,
|
||||
);
|
||||
|
||||
if (recovered) {
|
||||
iCloudHasToken = true;
|
||||
await _reloadActiveTokenModel(
|
||||
preferredStudentIdNorm: model.studentIdNorm,
|
||||
);
|
||||
|
||||
final recoveredExpiry = model.expiryDate;
|
||||
if (recoveredExpiry != null &&
|
||||
recoveredExpiry.isAfter(
|
||||
timeNow().add(const Duration(seconds: 60)),
|
||||
)) {
|
||||
logger.info(
|
||||
"[Recovery] Step 2 SUCCESS on attempt ${attempt + 1}: usable iCloud token applied without immediate refresh",
|
||||
);
|
||||
clearReauthFlag();
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[Recovery] Found iCloud token close to expiry, trying refresh...",
|
||||
);
|
||||
try {
|
||||
var tokenModel = await _refreshModelWithCrossDeviceLease(model);
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.tokenModels.put(tokenModel);
|
||||
});
|
||||
|
||||
model = tokenModel;
|
||||
await _syncTokenToAppleTargets(model);
|
||||
clearReauthFlag();
|
||||
logger.info("[Recovery] Step 2 SUCCESS on attempt ${attempt + 1}");
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.warning(
|
||||
"[Recovery] iCloud token refresh failed on attempt ${attempt + 1}: $e",
|
||||
);
|
||||
iCloudHasToken = true;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
"[Recovery] No fresh token in iCloud on attempt ${attempt + 1}",
|
||||
);
|
||||
if (attempt == 0) {
|
||||
iCloudHasToken = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.warning("[Recovery] All recovery attempts failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> refreshTokenProactively() async {
|
||||
final now = timeNow();
|
||||
final fiveMinutesFromNow = now.add(const Duration(minutes: 5));
|
||||
|
||||
if (model.expiryDate == null ||
|
||||
model.expiryDate!.isBefore(fiveMinutesFromNow)) {
|
||||
logger.info(
|
||||
"[Proactive] Token expired or expiring soon, starting recovery...",
|
||||
);
|
||||
|
||||
final recovered = await recoverToken();
|
||||
if (recovered) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.warning("[Proactive] Token recovery failed");
|
||||
await _setReauthFlag();
|
||||
if (Platform.isIOS && needsReauth) {
|
||||
try {
|
||||
_watchChannel.invokeMethod('notifyReauthRequired');
|
||||
} catch (e) {
|
||||
debugPrint('[KretaClient] Watch reauth notification skipped: $e');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.fine(
|
||||
"[Proactive] Token still valid until ${model.expiryDate}, no refresh needed",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<T> _mutexCallback<T>(Future<T> Function() callback) async {
|
||||
const maxWaitTime = Duration(seconds: 30);
|
||||
|
||||
if (_tokenMutexCompleter != null) {
|
||||
try {
|
||||
await _tokenMutexCompleter!.future.timeout(
|
||||
maxWaitTime,
|
||||
onTimeout: () {
|
||||
logger.warning(
|
||||
"[Mutex] Timeout waiting for token mutex, forcing release",
|
||||
);
|
||||
if (_tokenMutexCompleter != null &&
|
||||
!_tokenMutexCompleter!.isCompleted) {
|
||||
_tokenMutexCompleter!.complete();
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
_tokenMutexCompleter = Completer<void>();
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
final completer = _tokenMutexCompleter;
|
||||
_tokenMutexCompleter = null;
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Response> _authReq(String method, String url, [Object? data]) async {
|
||||
var localToken = await _mutexCallback<String>(() async {
|
||||
var now = timeNow();
|
||||
|
||||
if (now.millisecondsSinceEpoch >=
|
||||
model.expiryDate!.millisecondsSinceEpoch) {
|
||||
logger.info(
|
||||
"Token expired at ${model.expiryDate}, starting recovery for user: ${model.studentId}",
|
||||
);
|
||||
|
||||
final recovered = await recoverToken();
|
||||
if (!recovered) {
|
||||
logger.warning("Token recovery failed for user: ${model.studentId}");
|
||||
throw TokenExpiredException();
|
||||
}
|
||||
}
|
||||
|
||||
return model.accessToken!;
|
||||
});
|
||||
|
||||
final headers = <String, String>{
|
||||
// "content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"accept": "*/*",
|
||||
"user-agent": Constants.userAgent,
|
||||
"authorization": "Bearer $localToken",
|
||||
"apiKey": "21ff6c25-d1da-4a68-a811-c881a6057463",
|
||||
};
|
||||
|
||||
return await dio.get(
|
||||
url,
|
||||
options: Options(method: method, headers: headers),
|
||||
data: data,
|
||||
);
|
||||
}
|
||||
|
||||
Future<(dynamic, int)> _authJson(
|
||||
String method,
|
||||
String url, [
|
||||
Object? data,
|
||||
]) async {
|
||||
Response<dynamic> resp;
|
||||
|
||||
try {
|
||||
logger.finest("Sending authenticated request to: $url");
|
||||
resp = await _authReq(method, url, data);
|
||||
if (!url.endsWith("TanuloAdatlap")) {
|
||||
logger.finest("Response: ${resp.statusCode} ${resp.data}");
|
||||
}
|
||||
|
||||
if (resp.statusCode == 200 || resp.statusCode == 201) {
|
||||
final responseData = resp.data;
|
||||
if (responseData == null ||
|
||||
(responseData is List && responseData.isEmpty) ||
|
||||
(responseData is Map && responseData.isEmpty)) {
|
||||
logger.warning(
|
||||
"API returned ${resp.statusCode} with empty data for: $url - possible stale session",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
if (ex is Error) {
|
||||
logger.shout(
|
||||
"Request to url: $url failed",
|
||||
ex.toString(),
|
||||
ex.stackTrace,
|
||||
);
|
||||
} else {
|
||||
logger.shout("Request to url: $url failed", ex.toString());
|
||||
}
|
||||
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return (resp.data, resp.statusCode!);
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<Lesson>>> _timetableCachingGet(
|
||||
DateTime weekday,
|
||||
bool forceCache,
|
||||
) async {
|
||||
var from = weekday.getMonday();
|
||||
return await _cachingGet(
|
||||
genCacheKey(from, model.studentIdNorm!),
|
||||
KretaEndpoints.getTimeTable(
|
||||
model.iss!,
|
||||
from,
|
||||
from.add(Duration(days: 6)),
|
||||
),
|
||||
forceCache,
|
||||
0,
|
||||
isar.timetableCacheModels,
|
||||
(key, resp) => TimetableCacheModel()
|
||||
..cacheKey = key
|
||||
..values = (resp as List<dynamic>)
|
||||
.map((item) => jsonEncode(item))
|
||||
.toList(),
|
||||
(cache) => cache.values
|
||||
.map((data) => Lesson.fromJson(jsonDecode(data)))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<R>>> _genericListedCachingGet<R>(
|
||||
CacheId id,
|
||||
String url,
|
||||
bool forceCache,
|
||||
R Function(dynamic) mapResultEntries,
|
||||
) async {
|
||||
return await _genericCachingGet(
|
||||
id,
|
||||
url,
|
||||
forceCache,
|
||||
(data) => (data as List<dynamic>).map(mapResultEntries).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ApiResponse<R>> _genericCachingGet<R>(
|
||||
CacheId id,
|
||||
String url,
|
||||
bool forceCache,
|
||||
R Function(dynamic) makeResult,
|
||||
) async {
|
||||
return await _cachingGet(
|
||||
// it would be *ideal* to use xor and left shift here, however
|
||||
// binary operations seem to round the number down to
|
||||
// 32 bits for some reason???
|
||||
(model.studentIdNorm! + ((id.index + 1) * pow(10, 11))) as Id,
|
||||
url,
|
||||
forceCache,
|
||||
0,
|
||||
isar.genericCacheModels,
|
||||
(key, resp) => GenericCacheModel()
|
||||
..cacheKey = key
|
||||
..cacheData = jsonEncode(resp),
|
||||
(cache) {
|
||||
return makeResult(jsonDecode(cache.cacheData!));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ApiResponse<R>> _cachingGet<T, R>(
|
||||
Id cacheKey,
|
||||
String url,
|
||||
bool forceCache,
|
||||
int counter,
|
||||
IsarCollection<T> collection,
|
||||
T Function(Id, dynamic) makeCache,
|
||||
R Function(T) makeResult,
|
||||
) async {
|
||||
var cache = await collection.get(cacheKey);
|
||||
|
||||
if (forceCache && cache != null) {
|
||||
logger.finest(
|
||||
"_cachingGet(forceCache: $forceCache}): decoding cached response for: $url",
|
||||
);
|
||||
return ApiResponse.cached(makeResult(cache));
|
||||
}
|
||||
|
||||
try {
|
||||
var (resp, statusCode) = await _authJson("GET", url);
|
||||
|
||||
if (statusCode >= 400 && cache != null) {
|
||||
logger.finest("request failed: $statusCode, using cache for: $url");
|
||||
return ApiResponse(makeResult(cache), statusCode, null, true);
|
||||
}
|
||||
|
||||
var newCache = makeCache(cacheKey, resp);
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
collection.put(newCache);
|
||||
});
|
||||
|
||||
return ApiResponse(makeResult(newCache), statusCode, null, false);
|
||||
} catch (ex) {
|
||||
if (_isTokenExpired(ex)) {
|
||||
logger.warning("Token expired, setting needsReauth flag");
|
||||
await _setReauthFlag();
|
||||
|
||||
return ApiResponse(null, 0, ex, false);
|
||||
}
|
||||
|
||||
if (ex is DioException && counter < backoffCount) {
|
||||
logger.finest("Retrying: $counter / $backoffCount");
|
||||
final backoffDelay = backoffMin + (counter * backoffStep);
|
||||
logger.finest("Waiting: $backoffDelay");
|
||||
await Future.delayed(Duration(milliseconds: backoffDelay));
|
||||
|
||||
return _cachingGet(
|
||||
cacheKey,
|
||||
url,
|
||||
forceCache,
|
||||
counter + 1,
|
||||
collection,
|
||||
makeCache,
|
||||
makeResult,
|
||||
);
|
||||
}
|
||||
|
||||
if (cache != null) {
|
||||
logger.finest("request failed, using cache for: $url");
|
||||
return ApiResponse(makeResult(cache), 0, ex, true);
|
||||
}
|
||||
|
||||
logger.finest("request failed, no cache for: $url");
|
||||
return ApiResponse(null, 0, ex, false);
|
||||
}
|
||||
}
|
||||
|
||||
ApiResponse<List<ClassGroupSubjectAverage>>? classGroupAveragesCache;
|
||||
|
||||
Future<ApiResponse<List<ClassGroupSubjectAverage>>> getClassGroupAverages(
|
||||
ClassGroup classGroup, {
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
if (classGroup.studyTask == null) {
|
||||
String? err = "classGroup.studyTask is null";
|
||||
logger.warning(err);
|
||||
return ApiResponse([], 0, err, false);
|
||||
}
|
||||
if (!forceCache) {
|
||||
classGroupAveragesCache = null;
|
||||
} else if (classGroupAveragesCache != null) {
|
||||
return classGroupAveragesCache!;
|
||||
}
|
||||
var studyTaskUid = classGroup.studyTask!.uid.toString().split(",").first;
|
||||
var resp = await _genericListedCachingGet(
|
||||
CacheId.getClassGroupAvg,
|
||||
KretaEndpoints.getClassGroupAvg(model.iss!, studyTaskUid),
|
||||
forceCache,
|
||||
(item) => ClassGroupSubjectAverage.fromJson(item),
|
||||
);
|
||||
|
||||
if (resp.err == null) {
|
||||
classGroupAveragesCache = ApiResponse.cached(resp.response);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
ApiResponse<Student>? studentCache;
|
||||
|
||||
Future<ApiResponse<Student>> getStudent({bool forceCache = true}) async {
|
||||
if (!forceCache) {
|
||||
studentCache = null;
|
||||
} else if (studentCache != null) {
|
||||
return studentCache!;
|
||||
}
|
||||
|
||||
return await _genericCachingGet(
|
||||
CacheId.getStudent,
|
||||
KretaEndpoints.getStudentUrl(model.iss!),
|
||||
forceCache,
|
||||
(cache) => Student.fromJson(cache),
|
||||
).then((resp) {
|
||||
if (resp.err == null) {
|
||||
studentCache = ApiResponse.cached(resp.response);
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
ApiResponse<List<ClassGroup>>? classGroupCache;
|
||||
|
||||
Future<ApiResponse<List<ClassGroup>>> getClassGroups({
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
if (!forceCache) {
|
||||
classGroupCache = null;
|
||||
} else {
|
||||
if (classGroupCache != null) return classGroupCache!;
|
||||
}
|
||||
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getClassGroup,
|
||||
KretaEndpoints.getClassGroups(model.iss!),
|
||||
forceCache,
|
||||
(item) => ClassGroup.fromJson(item),
|
||||
).then((resp) {
|
||||
if (resp.err == null) {
|
||||
classGroupCache = ApiResponse.cached(resp.response);
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
ApiResponse<List<NoticeBoardItem>>? noticeBoardCache;
|
||||
|
||||
Future<ApiResponse<List<NoticeBoardItem>>> getNoticeBoard({
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
if (!forceCache) {
|
||||
noticeBoardCache = null;
|
||||
} else if (noticeBoardCache != null) {
|
||||
return noticeBoardCache!;
|
||||
}
|
||||
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getNoticeBoard,
|
||||
KretaEndpoints.getNoticeBoard(model.iss!),
|
||||
forceCache,
|
||||
(item) => NoticeBoardItem.fromJson(item),
|
||||
).then((resp) {
|
||||
if (resp.err == null) {
|
||||
noticeBoardCache = ApiResponse.cached(resp.response);
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
ApiResponse<List<InfoBoardItem>>? infoBoardCache;
|
||||
|
||||
Future<ApiResponse<List<InfoBoardItem>>> getInfoBoard({
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
if (forceCache && infoBoardCache != null) return infoBoardCache!;
|
||||
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getInfoBoard,
|
||||
KretaEndpoints.getInfoBoard(model.iss!, from, to),
|
||||
forceCache,
|
||||
(item) => InfoBoardItem.fromJson(item),
|
||||
).then((resp) {
|
||||
if (resp.err == null) {
|
||||
infoBoardCache = ApiResponse.cached(resp.response);
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
ApiResponse<List<Grade>>? gradeCache;
|
||||
|
||||
Future<ApiResponse<List<Grade>>> getGrades({bool forceCache = true}) async {
|
||||
if (!forceCache) {
|
||||
gradeCache = null;
|
||||
} else if (gradeCache != null) {
|
||||
return gradeCache!;
|
||||
}
|
||||
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getGrades,
|
||||
KretaEndpoints.getGrades(model.iss!),
|
||||
forceCache,
|
||||
(item) => Grade.fromJson(item),
|
||||
).then((resp) {
|
||||
if (resp.err == null) {
|
||||
resp.response!.sort((a, b) => b.recordDate.compareTo(a.recordDate));
|
||||
gradeCache = ApiResponse.cached(resp.response);
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
ApiResponse<List<SubjectAverage>>? subjectAverageCache;
|
||||
|
||||
Future<ApiResponse<List<SubjectAverage>>> getSubjectAverage(
|
||||
ClassGroup classGroup, {
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
String? err;
|
||||
if (classGroup.studyTask == null) {
|
||||
err = "classGroup.studyTask is null";
|
||||
logger.warning(err);
|
||||
return ApiResponse(
|
||||
List<SubjectAverage>.empty(growable: true),
|
||||
0,
|
||||
err,
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (!forceCache) {
|
||||
subjectAverageCache = null;
|
||||
} else if (subjectAverageCache != null) {
|
||||
return subjectAverageCache!;
|
||||
}
|
||||
var studyTaskUid = classGroup.studyTask!.uid.toString().split(",").first;
|
||||
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getSubjectAvg,
|
||||
KretaEndpoints.getSubjectAvg(model.iss!, studyTaskUid),
|
||||
forceCache,
|
||||
(item) => SubjectAverage.fromJson(item),
|
||||
).then((resp) {
|
||||
if (resp.err == null) {
|
||||
subjectAverageCache = ApiResponse.cached(resp.response);
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<Homework>>> getHomework({
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
if (from == null && to == null) {
|
||||
DateTime now = timeNow();
|
||||
DateTime start = now.copyWith(month: 9, day: 1);
|
||||
from = now.isBefore(start) ? start.subtract(Duration(days: 365)) : start;
|
||||
}
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getHomework,
|
||||
KretaEndpoints.getHomework(model.iss!, from, to),
|
||||
forceCache,
|
||||
(item) => Homework.fromJson(item),
|
||||
);
|
||||
}
|
||||
|
||||
/// Automatically aligns requests to start at Monday and end at Sunday
|
||||
Future<ApiResponse<List<Lesson>>> getTimeTable(
|
||||
DateTime from,
|
||||
DateTime to, {
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
var lessons = List<Lesson>.empty(growable: true);
|
||||
String? err;
|
||||
bool cached = true;
|
||||
|
||||
for (
|
||||
var i = from.millisecondsSinceEpoch;
|
||||
i < to.millisecondsSinceEpoch;
|
||||
i += 604800000
|
||||
) {
|
||||
var weekday = DateTime.fromMillisecondsSinceEpoch(i);
|
||||
|
||||
var resp = await _timetableCachingGet(weekday, forceCache);
|
||||
if (resp.err != null) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
lessons.addAll(resp.response!);
|
||||
|
||||
if (!resp.cached) cached = false;
|
||||
}
|
||||
|
||||
lessons =
|
||||
lessons
|
||||
.where(
|
||||
(lesson) => lesson.start.isAfter(from) && lesson.end.isBefore(to),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.start.compareTo(b.start));
|
||||
|
||||
return ApiResponse(lessons, 200, err, cached);
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<AllLessons>>> getLessons({
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getLessons,
|
||||
KretaEndpoints.getLessons(model.iss!),
|
||||
forceCache,
|
||||
(item) => AllLessons.fromJson(item),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<Test>>> getTests({
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getTests,
|
||||
KretaEndpoints.getTests(model.iss!, from, to),
|
||||
forceCache,
|
||||
(item) => Test.fromJson(item),
|
||||
);
|
||||
}
|
||||
|
||||
ApiResponse<List<Omission>>? omissionsCache;
|
||||
|
||||
Future<ApiResponse<List<Omission>>> getOmissions({
|
||||
bool forceCache = true,
|
||||
}) async {
|
||||
if (!forceCache) {
|
||||
omissionsCache = null;
|
||||
} else {
|
||||
if (omissionsCache != null) return omissionsCache!;
|
||||
}
|
||||
return await _genericListedCachingGet(
|
||||
CacheId.getOmissions,
|
||||
KretaEndpoints.getOmissions(model.iss!),
|
||||
forceCache,
|
||||
(item) => Omission.fromJson(item),
|
||||
).then((resp) {
|
||||
if (resp.err == null) {
|
||||
resp.response!.sort((a, b) => a.date.compareTo(b.date));
|
||||
omissionsCache = ApiResponse.cached(resp.response);
|
||||
}
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
void evictMemCache() {
|
||||
studentCache = null;
|
||||
noticeBoardCache = null;
|
||||
gradeCache = null;
|
||||
omissionsCache = null;
|
||||
classGroupCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTokenExpired(Object ex) =>
|
||||
ex is TokenExpiredException || ex is InvalidGrantException;
|
||||
@@ -1,12 +1,5 @@
|
||||
import 'package:firka/helpers/api/model/class_group.dart';
|
||||
import 'package:firka/helpers/api/model/homework.dart';
|
||||
import 'package:firka/helpers/api/model/notice_board.dart';
|
||||
import 'package:firka/helpers/api/model/omission.dart';
|
||||
import 'package:firka/helpers/api/model/test.dart';
|
||||
import 'package:firka/helpers/api/model/timetable.dart';
|
||||
import 'package:kreta_api/kreta_api.dart';
|
||||
|
||||
import '../model/grade.dart';
|
||||
import '../model/student.dart';
|
||||
import 'kreta_client.dart';
|
||||
|
||||
bool getStudentFL = false;
|
||||
@@ -21,8 +14,9 @@ bool getTestsStreamFL = false;
|
||||
bool getOmissionsStreamFL = false;
|
||||
|
||||
extension KretaStream on KretaClient {
|
||||
Stream<ApiResponse<Student>> getStudentStream(
|
||||
{bool cacheOnly = true}) async* {
|
||||
Stream<ApiResponse<Student>> getStudentStream({
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getStudentFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -35,8 +29,9 @@ extension KretaStream on KretaClient {
|
||||
getStudentFL = false;
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<ClassGroup>>> getClassGroupsStream(
|
||||
{bool cacheOnly = true}) async* {
|
||||
Stream<ApiResponse<List<ClassGroup>>> getClassGroupsStream({
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getClassGroupsFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -49,8 +44,9 @@ extension KretaStream on KretaClient {
|
||||
getClassGroupsFL = false;
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<NoticeBoardItem>>> getNoticeBoardStream(
|
||||
{bool cacheOnly = true}) async* {
|
||||
Stream<ApiResponse<List<NoticeBoardItem>>> getNoticeBoardStream({
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getNoticeBoardStreamFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -63,8 +59,9 @@ extension KretaStream on KretaClient {
|
||||
getNoticeBoardStreamFL = false;
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<InfoBoardItem>>> getInfoBoardStream(
|
||||
{bool cacheOnly = true}) async* {
|
||||
Stream<ApiResponse<List<InfoBoardItem>>> getInfoBoardStream({
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getInfoBoardStreamFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -77,8 +74,9 @@ extension KretaStream on KretaClient {
|
||||
getInfoBoardStreamFL = false;
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<Grade>>> getGradesStream(
|
||||
{bool cacheOnly = true}) async* {
|
||||
Stream<ApiResponse<List<Grade>>> getGradesStream({
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getGradesStreamFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -92,8 +90,9 @@ extension KretaStream on KretaClient {
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<SubjectAverage>>> getSubjectAverageStream(
|
||||
ClassGroup classGroup,
|
||||
{bool cacheOnly = true}) async* {
|
||||
ClassGroup classGroup, {
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getSubjectAverageStreamFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -106,8 +105,9 @@ extension KretaStream on KretaClient {
|
||||
getSubjectAverageStreamFL = false;
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<Homework>>> getHomeworkStream(
|
||||
{bool cacheOnly = true}) async* {
|
||||
Stream<ApiResponse<List<Homework>>> getHomeworkStream({
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getHomeworkStreamFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -121,8 +121,10 @@ extension KretaStream on KretaClient {
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<Lesson>>> getTimeTableStream(
|
||||
DateTime from, DateTime to,
|
||||
{bool cacheOnly = true}) async* {
|
||||
DateTime from,
|
||||
DateTime to, {
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getTimeTableStreamFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -135,8 +137,9 @@ extension KretaStream on KretaClient {
|
||||
getTimeTableStreamFL = false;
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<Test>>> getTestsStream(
|
||||
{bool cacheOnly = true}) async* {
|
||||
Stream<ApiResponse<List<Test>>> getTestsStream({
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getTestsStreamFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||
@@ -149,8 +152,9 @@ extension KretaStream on KretaClient {
|
||||
getTestsStreamFL = false;
|
||||
}
|
||||
|
||||
Stream<ApiResponse<List<Omission>>> getOmissionsStream(
|
||||
{bool cacheOnly = true}) async* {
|
||||
Stream<ApiResponse<List<Omission>>> getOmissionsStream({
|
||||
bool cacheOnly = true,
|
||||
}) async* {
|
||||
while (getOmissionsStreamFL) {
|
||||
await Future.delayed(Duration(milliseconds: 10));
|
||||
}
|
||||