commit b75a5faba92f3b7d92c8ac06598f1040117e7225 Author: Armand <4831c0@proton.me> Date: Mon Feb 10 22:22:45 2025 +0100 Initial commit Co-authored-by: Zypherift diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..0d6b88c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,107 @@ +# /firka +firka/debug-info/ + +# /firka/android +**/.gradle/ +**gradle-wrapper.jar +**/gradlew +**/gradlew.bat +**/.kotlin/ +**/captures/ + +# flutter +**/.dart_tool/ +**/.flutter-plugins +**/.flutter-plugins-dependencies +**/pubspec.lock +**/.pub-cache/ +**/.pub/ +**/build/ +**/.packages +**/doc/api/ +**/ios/Flutter/.last_build_id +**/.symlinks/ + +# IDEs +.idea/ +.vscode/ +*.iml +*.ipr +*.iws + +# Miscellaneous +**.class +**.log +**.pyc +**.swp +**/.DS_Store +**/.atom/ +**/.build/ +**/.buildlog/ +**/.history +**/.svn/ +**/.swiftpm/ +**/migrate_working_dir/ +**/.plugin_symlinks/ +**/local.properties + +# symbols, obfuscations +**/app.*.symbols +**/app.*.map.json + +# android releated +**/android/app/debug +**/android/app/profile +**/android/app/release +**/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +**.jks +**key.properties +**.keystore +**keystore.properties +**local.properties +**GenerateedPluginRegistrant.java +**/android/app/.cxx/ +**.cxx/ + +# ios releated +**/Pods +**/Podfile.lock +**/UserInterfaceState.xcuserstate +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* +**/ios/Flutter/ + +# dart +**/.env* +**/*.dart.js +**/*.info.json + +# tools.py logs +tool_logs/build/*.log +tool_logs/d8dx_fix/*.log +tool_logs/pub_fix/*.log diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..5cede1f2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,15 @@ +[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/vendor/wear_plus"] + path = firka/vendor/wear_plus + url = https://git.qwit.cloud/firka/wear_plus +[submodule "firka/lib/l10n"] + path = firka/lib/l10n + url = https://github.com/QwIT-Development/firka-localization diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b136d18b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Flutter telepítése + +A flutter telepítéséhez a dokumentáció [itt](https://docs.flutter.dev/get-started/install) található. + +# 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\\\dev\brotli) +- Add hozzá a mappát ahova kicsomagoltad (C:\Users\\\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 + +# Keystore + +[Secrets dokumentáció](secrets/README.md) + +# Flutter l10n + +Flutter l10n fileok generálása + +```shell +flutter gen-l10n --template-arb-file app_hu.arb +``` + +# Android debug build + +A dev buildhez nem közelező keystore használata +```shell +$ cd firka +$ flutter build apk --debug --target-platform android-arm,android-arm64,android-x64 +``` + +# Android release build + +A release buildhez közelező egy keystore használata, illetve a saját flutter forkunk használata. + +## Custom flutter engine setupolása + +```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 +``` \ No newline at end of file diff --git a/CONTRIBUTING_en.md b/CONTRIBUTING_en.md new file mode 100644 index 00000000..c3bf9d61 --- /dev/null +++ b/CONTRIBUTING_en.md @@ -0,0 +1,57 @@ +# Installing flutter + +The documentation for installing flutter can be found [here](https://docs.flutter.dev/get-started/install). + +# 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\\\dev\brotli +- Add the directory (ex. C:\Users\\\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 + +# Keystore + +[Secrets docs](secrets/README_en.md) + +# Flutter l10n + +Generating flutter l10n files + +```shell +flutter gen-l10n --template-arb-file app_hu.arb +``` + +# Android debug build + +The dev build doesn't require using a custom keystore +```shell +$ cd firka +$ flutter build apk --debug --target-platform android-arm,android-arm64,android-x64 +``` + +# Android release build + +The release build requires using a custom keystore and our custom flutter fork + +## Setting up our flutter engine fork + +```shell +$ git clone https://git.firka.app/firka/flutter +$ cd flutter +$ . dev/tools/envsetup.sh +$ gclient sync -D +$ ./dev/tools/build_release.sh +``` + +## Building the release apk + +```shell +$ ./tools/linux/build_apk.sh main +``` \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 00000000..0a91e3b7 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,82 @@ +pipeline { + agent { label 'ubuntu' } + environment { + PATH = "/home/jenkins/development/flutter/bin:${env.PATH}" + } + + stages { + stage('Cleanup') { + steps { + script { + sh '''#!/bin/sh + set -x + fusermount -u secrets || true + ''' + } + } + } + + stage('Decrypt keys') { + when { + branch 'main' + } + 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 + ''' + } + } + + stage('Clone submodules') { + steps { + script { + sh 'git submodule update --init --recursive' + } + } + } + + stage('Build firka') { + steps { + sh 'bash -c "./tools/linux/build_apk.sh ' + env.BRANCH_NAME + '"' + } + } + + stage('Publish release artifacts') { + when { + branch 'main' + } + steps { + archiveArtifacts artifacts: 'firka/build/app/outputs/flutter-apk/app-*-release.apk', fingerprint: true + sh 'rm firka/build/app/outputs/flutter-apk/app-*-release.apk' + } + } + + stage('Publish debug artifacts') { + when { + not { + branch 'main' + } + } + steps { + archiveArtifacts artifacts: 'firka/build/app/outputs/flutter-apk/app-debug.apk', fingerprint: true + sh 'rm firka/build/app/outputs/flutter-apk/app-debug.apk' + } + } + } +} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..4e835ae7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,661 @@ +# GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +## How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for +the specific requirements. + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU AGPL, see . + diff --git a/README.md b/README.md new file mode 100644 index 00000000..3d97fae4 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Firka Napló + +| HU [Magyar](README.md) | EN [English](README_en.md) | +|-----------------------------------|---------------------------------------| + +[![Crowdin](https://badges.crowdin.net/firka/localized.svg)](https://crowdin.com/project/firka) + +## A projektről + +Firka egy alternatív [megnevezetlen naplóra] egy kliens alkalmazás. Az alkalmazás célja +egy modern, felhasználóbarát felület biztosítása a(z) [megnevezetlen napló] rendszerhez. + +A [fejlesztői környezet beállítsa](CONTRIBUTING.md) \ No newline at end of file diff --git a/README_en.md b/README_en.md new file mode 100644 index 00000000..3b689b1a --- /dev/null +++ b/README_en.md @@ -0,0 +1,13 @@ +# Firka + +| HU [Magyar](README.md) | EN [English](README_en.md) | +|-----------------------------------|---------------------------------------| + +[![Crowdin](https://badges.crowdin.net/firka/localized.svg)](https://crowdin.com/project/firka) + +## The project + +Firka is an alternative client for [an application to be named]. The app's goal is to be a modern +user friendly app for the [to be named] system. + +[Contribution guide](CONTRIBUTING_en.md) \ No newline at end of file diff --git a/firka/.gitignore b/firka/.gitignore new file mode 100644 index 00000000..2ce9e99f --- /dev/null +++ b/firka/.gitignore @@ -0,0 +1,47 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +coverage \ No newline at end of file diff --git a/firka/.metadata b/firka/.metadata new file mode 100644 index 00000000..bad05875 --- /dev/null +++ b/firka/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "d7b523b356d15fb81e7d340bbe52b47f93937323" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + - platform: ios + create_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + base_revision: d7b523b356d15fb81e7d340bbe52b47f93937323 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/firka/analysis_options.yaml b/firka/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/firka/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/firka/android/.gitignore b/firka/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/firka/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/firka/android/app/build.gradle.kts b/firka/android/app/build.gradle.kts new file mode 100644 index 00000000..d25b2de8 --- /dev/null +++ b/firka/android/app/build.gradle.kts @@ -0,0 +1,505 @@ +import org.apache.commons.io.FileUtils +import java.io.FileInputStream +import java.security.MessageDigest +import java.util.Properties +import java.util.zip.ZipEntry +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") + // 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" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + 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 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + 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"]}" + ) + + signingConfigs { + create("release") { + storeFile = store + storePassword = props["storePassword"] as String + keyPassword = props["keyPassword"] as String + keyAlias = props["keyAlias"] as String + } + } + } + + buildTypes { + getByName("debug") { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + } + release { + val config = signingConfigs.findByName("release") + + if (config != null) { + signingConfig = config + } else { + // This isn't an error, however by default flutter will hide warnings and etc. + // so the only way to make this show up in flutter build is to + // 1. make it an error + // 2. use println + // however, println doesn't bring enough attention to the warning + // so I decided to use logger.error + logger.error("[WARNING] No keystore specified! Using debug keys to sign the apk.") + logger.error("[WARNING] DO NOT STORE ANY SENSITIVE DATA INSIDE THE APP") + logger.error("[WARNING] Because an attacker could steal it, if you sideload their malicious app.") + } + + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } +} +dependencies { + implementation("androidx.wear:wear-ongoing:1.0.0") +} + +flutter { + source = "../.." +} + +tasks.register("transformAndResignDebugApk") { + group = "build" + description = "Transform and resign APK with debug key" + + dependsOn("assembleDebug") + + doLast { + transformApks(true) + } +} + +tasks.register("transformAndResignReleaseApk") { + group = "build" + description = "Transform and resign APK with release key" + + dependsOn("assembleRelease") + + doLast { + transformApks(false) + } +} + +tasks.register("transformAndResignReleaseBundle") { + group = "build" + description = "Transform and resign bundle with release key" + + dependsOn("bundleRelease") + + doLast { + transformAppBundle() + } +} + +afterEvaluate { + tasks.findByName("assembleDebug")?.finalizedBy("transformAndResignDebugApk") + tasks.findByName("assembleRelease")?.finalizedBy("transformAndResignReleaseApk") + tasks.findByName("bundleRelease")?.finalizedBy("transformAndResignReleaseBundle") +} + +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") + tempDir.deleteRecursively() + tempDir.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() + 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()) + 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" && optipng != null && f.extension == "png") { + exec { + commandLine( + optipng, + "-zm", "9", + "-zw", "32k", + "-o9", + f.absolutePath + ) + } + } + + 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() + } + zos.close() + + ant.invokeMethod("zip", mapOf( + "destfile" to output.absolutePath, + "basedir" to tempDir.absolutePath, + "level" to 0 + )) + + tempDir.deleteRecursively() + println("APK transformed successfully") +} + +fun transformAppBundle() { + val buildDir = project.buildDir + val bundle = File(buildDir, "outputs/bundle/release/app-release.aab") + + 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() + + copy { + from(zipTree(bundle)) + into(aabTempDir) + } + + // TODO: Finish + +} + +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 { + 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") + + 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 + } + } + } + } + + 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 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", 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") +} \ No newline at end of file diff --git a/firka/android/app/proguard-rules.pro b/firka/android/app/proguard-rules.pro new file mode 100644 index 00000000..1b493d58 --- /dev/null +++ b/firka/android/app/proguard-rules.pro @@ -0,0 +1 @@ +-keep class org.brotli.** { *; } \ No newline at end of file diff --git a/firka/android/app/src/debug/AndroidManifest.xml b/firka/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..0f06c9a1 --- /dev/null +++ b/firka/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/firka/android/app/src/main/AndroidManifest.xml b/firka/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..526221c3 --- /dev/null +++ b/firka/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/firka/android/app/src/main/java/org/brotli/common/SharedDictionaryType.java b/firka/android/app/src/main/java/org/brotli/common/SharedDictionaryType.java new file mode 100644 index 00000000..b592d2b4 --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/common/SharedDictionaryType.java @@ -0,0 +1,15 @@ +/* Copyright 2018 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ +package org.brotli.common; + +/** POJO enum that mirrors C BrotliSharedDictionaryType. */ +public class SharedDictionaryType { + // Disallow instantiation. + private SharedDictionaryType() {} + + public static final int RAW = 0; + public static final int SERIALIZED = 1; +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/BitReader.java b/firka/android/app/src/main/java/org/brotli/dec/BitReader.java new file mode 100644 index 00000000..bae9452d --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/BitReader.java @@ -0,0 +1,289 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +/** + * Bit reading helpers. + */ +final class BitReader { + + // Possible values: {5, 6}. 5 corresponds to 32-bit build, 6 to 64-bit. This value is used for + // JIT conditional compilation. + private static final int LOG_BITNESS = Utils.getLogBintness(); + + // Not only Java compiler prunes "if (const false)" code, but JVM as well. + // Code under "if (DEBUG != 0)" have zero performance impact (outside unit tests). + private static final int DEBUG = Utils.isDebugMode(); + + static final int BITNESS = 1 << LOG_BITNESS; + + private static final int BYTENESS = BITNESS / 8; + private static final int CAPACITY = 4096; + // After encountering the end of the input stream, this amount of zero bytes will be appended. + private static final int SLACK = 64; + private static final int BUFFER_SIZE = CAPACITY + SLACK; + // Don't bother to replenish the buffer while this number of bytes is available. + private static final int SAFEGUARD = 36; + private static final int WATERLINE = CAPACITY - SAFEGUARD; + + // "Half" refers to "half of native integer type", i.e. on 64-bit machines it is 32-bit type, + // on 32-bit machines it is 16-bit. + private static final int HALF_BITNESS = BITNESS / 2; + private static final int HALF_SIZE = BYTENESS / 2; + private static final int HALVES_CAPACITY = CAPACITY / HALF_SIZE; + private static final int HALF_BUFFER_SIZE = BUFFER_SIZE / HALF_SIZE; + private static final int HALF_WATERLINE = WATERLINE / HALF_SIZE; + + private static final int LOG_HALF_SIZE = LOG_BITNESS - 4; + + /** + * Fills up the input buffer. + * + *

No-op if there are at least 36 bytes present after current position. + * + *

After encountering the end of the input stream, 64 additional zero bytes are copied to the + * buffer. + */ + static void readMoreInput(State s) { + if (s.halfOffset > HALF_WATERLINE) { + doReadMoreInput(s); + } + } + + static void doReadMoreInput(State s) { + if (s.endOfStreamReached != 0) { + if (halfAvailable(s) >= -2) { + return; + } + throw new BrotliRuntimeException("No more input"); + } + final int readOffset = s.halfOffset << LOG_HALF_SIZE; + int bytesInBuffer = CAPACITY - readOffset; + // Move unused bytes to the head of the buffer. + Utils.copyBytesWithin(s.byteBuffer, 0, readOffset, CAPACITY); + s.halfOffset = 0; + while (bytesInBuffer < CAPACITY) { + final int spaceLeft = CAPACITY - bytesInBuffer; + final int len = Utils.readInput(s.input, s.byteBuffer, bytesInBuffer, spaceLeft); + // EOF is -1 in Java, but 0 in C#. + if (len <= 0) { + s.endOfStreamReached = 1; + s.tailBytes = bytesInBuffer; + bytesInBuffer += HALF_SIZE - 1; + break; + } + bytesInBuffer += len; + } + bytesToNibbles(s, bytesInBuffer); + } + + static void checkHealth(State s, int endOfStream) { + if (s.endOfStreamReached == 0) { + return; + } + final int byteOffset = (s.halfOffset << LOG_HALF_SIZE) + ((s.bitOffset + 7) >> 3) - BYTENESS; + if (byteOffset > s.tailBytes) { + throw new BrotliRuntimeException("Read after end"); + } + if ((endOfStream != 0) && (byteOffset != s.tailBytes)) { + throw new BrotliRuntimeException("Unused bytes after end"); + } + } + + static void assertAccumulatorHealthy(State s) { + if (s.bitOffset > BITNESS) { + throw new IllegalStateException("Accumulator underloaded: " + s.bitOffset); + } + } + + static void fillBitWindow(State s) { + if (DEBUG != 0) { + assertAccumulatorHealthy(s); + } + if (s.bitOffset >= HALF_BITNESS) { + // Same as doFillBitWindow. JVM fails to inline it. + if (BITNESS == 64) { + s.accumulator64 = ((long) s.intBuffer[s.halfOffset++] << HALF_BITNESS) + | (s.accumulator64 >>> HALF_BITNESS); + } else { + s.accumulator32 = ((int) s.shortBuffer[s.halfOffset++] << HALF_BITNESS) + | (s.accumulator32 >>> HALF_BITNESS); + } + s.bitOffset -= HALF_BITNESS; + } + } + + static void doFillBitWindow(State s) { + if (DEBUG != 0) { + assertAccumulatorHealthy(s); + } + if (BITNESS == 64) { + s.accumulator64 = ((long) s.intBuffer[s.halfOffset++] << HALF_BITNESS) + | (s.accumulator64 >>> HALF_BITNESS); + } else { + s.accumulator32 = ((int) s.shortBuffer[s.halfOffset++] << HALF_BITNESS) + | (s.accumulator32 >>> HALF_BITNESS); + } + s.bitOffset -= HALF_BITNESS; + } + + static int peekBits(State s) { + if (BITNESS == 64) { + return (int) (s.accumulator64 >>> s.bitOffset); + } else { + return s.accumulator32 >>> s.bitOffset; + } + } + + /** + * Fetches bits from accumulator. + * + * WARNING: accumulator MUST contain at least the specified amount of bits, + * otherwise BitReader will become broken. + */ + static int readFewBits(State s, int n) { + final int val = peekBits(s) & ((1 << n) - 1); + s.bitOffset += n; + return val; + } + + static int readBits(State s, int n) { + if (HALF_BITNESS >= 24) { + return readFewBits(s, n); + } else { + return (n <= 16) ? readFewBits(s, n) : readManyBits(s, n); + } + } + + private static int readManyBits(State s, int n) { + final int low = readFewBits(s, 16); + doFillBitWindow(s); + return low | (readFewBits(s, n - 16) << 16); + } + + static void initBitReader(State s) { + s.byteBuffer = new byte[BUFFER_SIZE]; + if (BITNESS == 64) { + s.accumulator64 = 0; + s.intBuffer = new int[HALF_BUFFER_SIZE]; + } else { + s.accumulator32 = 0; + s.shortBuffer = new short[HALF_BUFFER_SIZE]; + } + s.bitOffset = BITNESS; + s.halfOffset = HALVES_CAPACITY; + s.endOfStreamReached = 0; + prepare(s); + } + + private static void prepare(State s) { + readMoreInput(s); + checkHealth(s, 0); + doFillBitWindow(s); + doFillBitWindow(s); + } + + static void reload(State s) { + if (s.bitOffset == BITNESS) { + prepare(s); + } + } + + static void jumpToByteBoundary(State s) { + final int padding = (BITNESS - s.bitOffset) & 7; + if (padding != 0) { + final int paddingBits = readFewBits(s, padding); + if (paddingBits != 0) { + throw new BrotliRuntimeException("Corrupted padding bits"); + } + } + } + + static int halfAvailable(State s) { + int limit = HALVES_CAPACITY; + if (s.endOfStreamReached != 0) { + limit = (s.tailBytes + (HALF_SIZE - 1)) >> LOG_HALF_SIZE; + } + return limit - s.halfOffset; + } + + static void copyRawBytes(State s, byte[] data, int offset, int length) { + if ((s.bitOffset & 7) != 0) { + throw new BrotliRuntimeException("Unaligned copyBytes"); + } + + // Drain accumulator. + while ((s.bitOffset != BITNESS) && (length != 0)) { + data[offset++] = (byte) peekBits(s); + s.bitOffset += 8; + length--; + } + if (length == 0) { + return; + } + + // Get data from shadow buffer with "sizeof(int)" granularity. + final int copyNibbles = Math.min(halfAvailable(s), length >> LOG_HALF_SIZE); + if (copyNibbles > 0) { + final int readOffset = s.halfOffset << LOG_HALF_SIZE; + final int delta = copyNibbles << LOG_HALF_SIZE; + System.arraycopy(s.byteBuffer, readOffset, data, offset, delta); + offset += delta; + length -= delta; + s.halfOffset += copyNibbles; + } + if (length == 0) { + return; + } + + // Read tail bytes. + if (halfAvailable(s) > 0) { + // length = 1..3 + fillBitWindow(s); + while (length != 0) { + data[offset++] = (byte) peekBits(s); + s.bitOffset += 8; + length--; + } + checkHealth(s, 0); + return; + } + + // Now it is possible to copy bytes directly. + while (length > 0) { + final int len = Utils.readInput(s.input, data, offset, length); + if (len == -1) { + throw new BrotliRuntimeException("Unexpected end of input"); + } + offset += len; + length -= len; + } + } + + /** + * Translates bytes to halves (int/short). + */ + static void bytesToNibbles(State s, int byteLen) { + final byte[] byteBuffer = s.byteBuffer; + final int halfLen = byteLen >> LOG_HALF_SIZE; + if (BITNESS == 64) { + final int[] intBuffer = s.intBuffer; + for (int i = 0; i < halfLen; ++i) { + intBuffer[i] = ((byteBuffer[i * 4] & 0xFF)) + | ((byteBuffer[(i * 4) + 1] & 0xFF) << 8) + | ((byteBuffer[(i * 4) + 2] & 0xFF) << 16) + | ((byteBuffer[(i * 4) + 3] & 0xFF) << 24); + } + } else { + final short[] shortBuffer = s.shortBuffer; + for (int i = 0; i < halfLen; ++i) { + shortBuffer[i] = (short) ((byteBuffer[i * 2] & 0xFF) + | ((byteBuffer[(i * 2) + 1] & 0xFF) << 8)); + } + } + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/BrotliInputStream.java b/firka/android/app/src/main/java/org/brotli/dec/BrotliInputStream.java new file mode 100644 index 00000000..7bbe2f63 --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/BrotliInputStream.java @@ -0,0 +1,172 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +import java.io.IOException; +import java.io.InputStream; + +/** + * {@link InputStream} decorator that decompresses brotli data. + * + *

Not thread-safe. + */ +public class BrotliInputStream extends InputStream { + + public static final int DEFAULT_INTERNAL_BUFFER_SIZE = 256; + + /** + * Value expected by InputStream contract when stream is over. + * + * In Java it is -1. + * In C# it is 0 (should be patched during transpilation). + */ + private static final int END_OF_STREAM_MARKER = -1; + + /** + * Internal buffer used for efficient byte-by-byte reading. + */ + private byte[] buffer; + + /** + * Number of decoded but still unused bytes in internal buffer. + */ + private int remainingBufferBytes; + + /** + * Next unused byte offset. + */ + private int bufferOffset; + + /** + * Decoder state. + */ + private final State state = new State(); + + /** + * Creates a {@link InputStream} wrapper that decompresses brotli data. + * + *

For byte-by-byte reading ({@link #read()}) internal buffer with + * {@link #DEFAULT_INTERNAL_BUFFER_SIZE} size is allocated and used. + * + *

Will block the thread until first {@link BitReader#CAPACITY} bytes of data of source + * are available. + * + * @param source underlying data source + * @throws IOException in case of corrupted data or source stream problems + */ + public BrotliInputStream(InputStream source) throws IOException { + this(source, DEFAULT_INTERNAL_BUFFER_SIZE); + } + + /** + * Creates a {@link InputStream} wrapper that decompresses brotli data. + * + *

For byte-by-byte reading ({@link #read()}) internal buffer of specified size is + * allocated and used. + * + *

Will block the thread until first {@link BitReader#CAPACITY} bytes of data of source + * are available. + * + * @param source compressed data source + * @param byteReadBufferSize size of internal buffer used in case of + * byte-by-byte reading + * @throws IOException in case of corrupted data or source stream problems + */ + public BrotliInputStream(InputStream source, int byteReadBufferSize) throws IOException { + if (byteReadBufferSize <= 0) { + throw new IllegalArgumentException("Bad buffer size:" + byteReadBufferSize); + } else if (source == null) { + throw new IllegalArgumentException("source is null"); + } + this.buffer = new byte[byteReadBufferSize]; + this.remainingBufferBytes = 0; + this.bufferOffset = 0; + try { + Decode.initState(state, source); + } catch (BrotliRuntimeException ex) { + throw new IOException("Brotli decoder initialization failed", ex); + } + } + + public void attachDictionaryChunk(byte[] data) { + Decode.attachDictionaryChunk(state, data); + } + + public void enableEagerOutput() { + Decode.enableEagerOutput(state); + } + + public void enableLargeWindow() { + Decode.enableLargeWindow(state); + } + + /** + * {@inheritDoc} + */ + @Override + public void close() throws IOException { + Decode.close(state); + } + + /** + * {@inheritDoc} + */ + @Override + public int read() throws IOException { + if (bufferOffset >= remainingBufferBytes) { + remainingBufferBytes = read(buffer, 0, buffer.length); + bufferOffset = 0; + if (remainingBufferBytes == END_OF_STREAM_MARKER) { + // Both Java and C# return the same value for EOF on single-byte read. + return -1; + } + } + return buffer[bufferOffset++] & 0xFF; + } + + /** + * {@inheritDoc} + */ + @Override + public int read(byte[] destBuffer, int destOffset, int destLen) throws IOException { + if (destOffset < 0) { + throw new IllegalArgumentException("Bad offset: " + destOffset); + } else if (destLen < 0) { + throw new IllegalArgumentException("Bad length: " + destLen); + } else if (destOffset + destLen > destBuffer.length) { + throw new IllegalArgumentException( + "Buffer overflow: " + (destOffset + destLen) + " > " + destBuffer.length); + } else if (destLen == 0) { + return 0; + } + int copyLen = Math.max(remainingBufferBytes - bufferOffset, 0); + if (copyLen != 0) { + copyLen = Math.min(copyLen, destLen); + System.arraycopy(buffer, bufferOffset, destBuffer, destOffset, copyLen); + bufferOffset += copyLen; + destOffset += copyLen; + destLen -= copyLen; + if (destLen == 0) { + return copyLen; + } + } + try { + state.output = destBuffer; + state.outputOffset = destOffset; + state.outputLength = destLen; + state.outputUsed = 0; + Decode.decompress(state); + copyLen += state.outputUsed; + copyLen = (copyLen > 0) ? copyLen : END_OF_STREAM_MARKER; + return copyLen; + } catch (BrotliRuntimeException ex) { + throw new IOException("Brotli stream decoding failed", ex); + } + + // <{[INJECTED CODE]}> + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/BrotliRuntimeException.java b/firka/android/app/src/main/java/org/brotli/dec/BrotliRuntimeException.java new file mode 100644 index 00000000..18449072 --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/BrotliRuntimeException.java @@ -0,0 +1,21 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +/** + * Unchecked exception used internally. + */ +class BrotliRuntimeException extends RuntimeException { + + BrotliRuntimeException(String message) { + super(message); + } + + BrotliRuntimeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/Context.java b/firka/android/app/src/main/java/org/brotli/dec/Context.java new file mode 100644 index 00000000..10bf0cbc --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/Context.java @@ -0,0 +1,58 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +/** + * Common context lookup table for all context modes. + */ +final class Context { + + static final int[] LOOKUP = new int[2048]; + + private static final String UTF_MAP = " !! ! \"#$##%#$&'##(#)#+++++++++" + + "+((&*'##,---,---,-----,-----,-----&#'###.///.///./////./////./////&#'# "; + private static final String UTF_RLE = "A/* ': & : $ \u0081 @"; + + private static void unpackLookupTable(int[] lookup, String map, String rle) { + // LSB6, MSB6, SIGNED + for (int i = 0; i < 256; ++i) { + lookup[i] = i & 0x3F; + lookup[512 + i] = i >> 2; + lookup[1792 + i] = 2 + (i >> 6); + } + // UTF8 + for (int i = 0; i < 128; ++i) { + lookup[1024 + i] = 4 * (map.charAt(i) - 32); + } + for (int i = 0; i < 64; ++i) { + lookup[1152 + i] = i & 1; + lookup[1216 + i] = 2 + (i & 1); + } + int offset = 1280; + for (int k = 0; k < 19; ++k) { + final int value = k & 3; + final int rep = rle.charAt(k) - 32; + for (int i = 0; i < rep; ++i) { + lookup[offset++] = value; + } + } + // SIGNED + for (int i = 0; i < 16; ++i) { + lookup[1792 + i] = 1; + lookup[2032 + i] = 6; + } + lookup[1792] = 0; + lookup[2047] = 7; + for (int i = 0; i < 256; ++i) { + lookup[1536 + i] = lookup[1792 + i] << 3; + } + } + + static { + unpackLookupTable(LOOKUP, UTF_MAP, UTF_RLE); + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/Decode.java b/firka/android/app/src/main/java/org/brotli/dec/Decode.java new file mode 100644 index 00000000..bf9b6817 --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/Decode.java @@ -0,0 +1,1357 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/** + * API for Brotli decompression. + */ +final class Decode { + + static final int MIN_LARGE_WINDOW_BITS = 10; + /* Maximum was chosen to be 30 to allow efficient decoder implementation. + * Format allows bigger window, but Java does not support 2G+ arrays. */ + static final int MAX_LARGE_WINDOW_BITS = 30; + + //---------------------------------------------------------------------------- + // RunningState + //---------------------------------------------------------------------------- + private static final int UNINITIALIZED = 0; + private static final int INITIALIZED = 1; + private static final int BLOCK_START = 2; + private static final int COMPRESSED_BLOCK_START = 3; + private static final int MAIN_LOOP = 4; + private static final int READ_METADATA = 5; + private static final int COPY_UNCOMPRESSED = 6; + private static final int INSERT_LOOP = 7; + private static final int COPY_LOOP = 8; + private static final int USE_DICTIONARY = 9; + private static final int FINISHED = 10; + private static final int CLOSED = 11; + private static final int INIT_WRITE = 12; + private static final int WRITE = 13; + private static final int COPY_FROM_COMPOUND_DICTIONARY = 14; + + private static final int DEFAULT_CODE_LENGTH = 8; + private static final int CODE_LENGTH_REPEAT_CODE = 16; + private static final int NUM_LITERAL_CODES = 256; + private static final int NUM_COMMAND_CODES = 704; + private static final int NUM_BLOCK_LENGTH_CODES = 26; + private static final int LITERAL_CONTEXT_BITS = 6; + private static final int DISTANCE_CONTEXT_BITS = 2; + + private static final int CD_BLOCK_MAP_BITS = 8; + private static final int HUFFMAN_TABLE_BITS = 8; + private static final int HUFFMAN_TABLE_MASK = 0xFF; + + /** + * Maximum possible Huffman table size for an alphabet size of (index * 32), + * max code length 15 and root table bits 8. + * The biggest alphabet is "command" - 704 symbols. Though "distance" alphabet could theoretically + * outreach that limit (for 62 extra bit distances), practically it is limited by + * MAX_ALLOWED_DISTANCE and never gets bigger than 544 symbols. + */ + static final int[] MAX_HUFFMAN_TABLE_SIZE = { + 256, 402, 436, 468, 500, 534, 566, 598, 630, 662, 694, 726, 758, 790, 822, + 854, 886, 920, 952, 984, 1016, 1048, 1080 + }; + + private static final int HUFFMAN_TABLE_SIZE_26 = 396; + private static final int HUFFMAN_TABLE_SIZE_258 = 632; + + private static final int CODE_LENGTH_CODES = 18; + private static final int[] CODE_LENGTH_CODE_ORDER = { + 1, 2, 3, 4, 0, 5, 17, 6, 16, 7, 8, 9, 10, 11, 12, 13, 14, 15, + }; + + private static final int NUM_DISTANCE_SHORT_CODES = 16; + private static final int[] DISTANCE_SHORT_CODE_INDEX_OFFSET = { + 0, 3, 2, 1, 0, 0, 0, 0, 0, 0, 3, 3, 3, 3, 3, 3 + }; + + private static final int[] DISTANCE_SHORT_CODE_VALUE_OFFSET = { + 0, 0, 0, 0, -1, 1, -2, 2, -3, 3, -1, 1, -2, 2, -3, 3 + }; + + /** + * Static Huffman code for the code length code lengths. + */ + private static final int[] FIXED_TABLE = { + 0x020000, 0x020004, 0x020003, 0x030002, 0x020000, 0x020004, 0x020003, 0x040001, + 0x020000, 0x020004, 0x020003, 0x030002, 0x020000, 0x020004, 0x020003, 0x040005 + }; + + // TODO(eustas): generalize. + static final int MAX_TRANSFORMED_WORD_LENGTH = 5 + 24 + 8; + + private static final int MAX_DISTANCE_BITS = 24; + private static final int MAX_LARGE_WINDOW_DISTANCE_BITS = 62; + + /** + * Safe distance limit. + * + * Limit ((1 << 31) - 4) allows safe distance calculation without overflows, + * given the distance alphabet size is limited to corresponding size. + */ + private static final int MAX_ALLOWED_DISTANCE = 0x7FFFFFFC; + + //---------------------------------------------------------------------------- + // Prefix code LUT. + //---------------------------------------------------------------------------- + static final int[] BLOCK_LENGTH_OFFSET = { + 1, 5, 9, 13, 17, 25, 33, 41, 49, 65, 81, 97, 113, 145, 177, 209, 241, 305, 369, 497, + 753, 1265, 2289, 4337, 8433, 16625 + }; + + static final int[] BLOCK_LENGTH_N_BITS = { + 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 7, 8, 9, 10, 11, 12, 13, 24 + }; + + static final short[] INSERT_LENGTH_N_BITS = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x02, 0x03, 0x03, + 0x04, 0x04, 0x05, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0C, 0x0E, 0x18 + }; + + static final short[] COPY_LENGTH_N_BITS = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x02, + 0x03, 0x03, 0x04, 0x04, 0x05, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x18 + }; + + // Each command is represented with 4x16-bit values: + // * [insertLenExtraBits, copyLenExtraBits] + // * insertLenOffset + // * copyLenOffset + // * distanceContext + static final short[] CMD_LOOKUP = new short[NUM_COMMAND_CODES * 4]; + + static { + unpackCommandLookupTable(CMD_LOOKUP); + } + + private static int log2floor(int i) { + int result = -1; + int step = 16; + while (step > 0) { + if ((i >>> step) != 0) { + result += step; + i = i >>> step; + } + step = step >> 1; + } + return result + i; + } + + private static int calculateDistanceAlphabetSize(int npostfix, int ndirect, int maxndistbits) { + return NUM_DISTANCE_SHORT_CODES + ndirect + 2 * (maxndistbits << npostfix); + } + + // TODO(eustas): add a correctness test for this function when + // large-window and dictionary are implemented. + private static int calculateDistanceAlphabetLimit(int maxDistance, int npostfix, int ndirect) { + if (maxDistance < ndirect + (2 << npostfix)) { + throw new IllegalArgumentException("maxDistance is too small"); + } + final int offset = ((maxDistance - ndirect) >> npostfix) + 4; + final int ndistbits = log2floor(offset) - 1; + final int group = ((ndistbits - 1) << 1) | ((offset >> ndistbits) & 1); + return ((group - 1) << npostfix) + (1 << npostfix) + ndirect + NUM_DISTANCE_SHORT_CODES; + } + + private static void unpackCommandLookupTable(short[] cmdLookup) { + final short[] insertLengthOffsets = new short[24]; + final short[] copyLengthOffsets = new short[24]; + copyLengthOffsets[0] = 2; + for (int i = 0; i < 23; ++i) { + insertLengthOffsets[i + 1] = + (short) (insertLengthOffsets[i] + (1 << INSERT_LENGTH_N_BITS[i])); + copyLengthOffsets[i + 1] = + (short) (copyLengthOffsets[i] + (1 << COPY_LENGTH_N_BITS[i])); + } + + for (int cmdCode = 0; cmdCode < NUM_COMMAND_CODES; ++cmdCode) { + int rangeIdx = cmdCode >>> 6; + /* -4 turns any regular distance code to negative. */ + int distanceContextOffset = -4; + if (rangeIdx >= 2) { + rangeIdx -= 2; + distanceContextOffset = 0; + } + final int insertCode = (((0x29850 >>> (rangeIdx * 2)) & 0x3) << 3) | ((cmdCode >>> 3) & 7); + final int copyCode = (((0x26244 >>> (rangeIdx * 2)) & 0x3) << 3) | (cmdCode & 7); + final short copyLengthOffset = copyLengthOffsets[copyCode]; + final int distanceContext = + distanceContextOffset + (copyLengthOffset > 4 ? 3 : copyLengthOffset - 2); + final int index = cmdCode * 4; + cmdLookup[index + 0] = + (short) (INSERT_LENGTH_N_BITS[insertCode] | (COPY_LENGTH_N_BITS[copyCode] << 8)); + cmdLookup[index + 1] = insertLengthOffsets[insertCode]; + cmdLookup[index + 2] = copyLengthOffsets[copyCode]; + cmdLookup[index + 3] = (short) distanceContext; + } + } + + /** + * Reads brotli stream header and parses "window bits". + * + * @param s initialized state, before any read is performed. + * @return -1 if header is invalid + */ + private static int decodeWindowBits(State s) { + /* Change the meaning of flag. Before that step it means "decoder must be capable of reading + * "large-window" brotli stream. After this step it means that "large-window" feature + * is actually detected. Despite the window size could be same as before (lgwin = 10..24), + * encoded distances are allowed to be much greater, thus bigger dictinary could be used. */ + final int largeWindowEnabled = s.isLargeWindow; + s.isLargeWindow = 0; + + BitReader.fillBitWindow(s); + if (BitReader.readFewBits(s, 1) == 0) { + return 16; + } + int n = BitReader.readFewBits(s, 3); + if (n != 0) { + return 17 + n; + } + n = BitReader.readFewBits(s, 3); + if (n != 0) { + if (n == 1) { + if (largeWindowEnabled == 0) { + /* Reserved value in regular brotli stream. */ + return -1; + } + s.isLargeWindow = 1; + /* Check "reserved" bit for future (post-large-window) extensions. */ + if (BitReader.readFewBits(s, 1) == 1) { + return -1; + } + n = BitReader.readFewBits(s, 6); + if (n < MIN_LARGE_WINDOW_BITS || n > MAX_LARGE_WINDOW_BITS) { + /* Encoded window bits value is too small or too big. */ + return -1; + } + return n; + } else { + return 8 + n; + } + } + return 17; + } + + /** + * Switch decoder to "eager" mode. + * + * In "eager" mode decoder returns as soon as there is enough data to fill output buffer. + * + * @param s initialized state, before any read is performed. + */ + static void enableEagerOutput(State s) { + if (s.runningState != INITIALIZED) { + throw new IllegalStateException("State MUST be freshly initialized"); + } + s.isEager = 1; + } + + static void enableLargeWindow(State s) { + if (s.runningState != INITIALIZED) { + throw new IllegalStateException("State MUST be freshly initialized"); + } + s.isLargeWindow = 1; + } + + // TODO(eustas): do we need byte views? + static void attachDictionaryChunk(State s, byte[] data) { + if (s.runningState != INITIALIZED) { + throw new IllegalStateException("State MUST be freshly initialized"); + } + if (s.cdNumChunks == 0) { + s.cdChunks = new byte[16][]; + s.cdChunkOffsets = new int[16]; + s.cdBlockBits = -1; + } + if (s.cdNumChunks == 15) { + throw new IllegalStateException("Too many dictionary chunks"); + } + s.cdChunks[s.cdNumChunks] = data; + s.cdNumChunks++; + s.cdTotalSize += data.length; + s.cdChunkOffsets[s.cdNumChunks] = s.cdTotalSize; + } + + /** + * Associate input with decoder state. + * + * @param s uninitialized state without associated input + * @param input compressed data source + */ + static void initState(State s, InputStream input) { + if (s.runningState != UNINITIALIZED) { + throw new IllegalStateException("State MUST be uninitialized"); + } + /* 6 trees + 1 extra "offset" slot to simplify table decoding logic. */ + s.blockTrees = new int[7 + 3 * (HUFFMAN_TABLE_SIZE_258 + HUFFMAN_TABLE_SIZE_26)]; + s.blockTrees[0] = 7; + s.distRbIdx = 3; + final int maxDistanceAlphabetLimit = + calculateDistanceAlphabetLimit(MAX_ALLOWED_DISTANCE, 3, 15 << 3); + s.distExtraBits = new byte[maxDistanceAlphabetLimit]; + s.distOffset = new int[maxDistanceAlphabetLimit]; + s.input = input; + BitReader.initBitReader(s); + s.runningState = INITIALIZED; + } + + static void close(State s) throws IOException { + if (s.runningState == UNINITIALIZED) { + throw new IllegalStateException("State MUST be initialized"); + } + if (s.runningState == CLOSED) { + return; + } + s.runningState = CLOSED; + if (s.input != null) { + Utils.closeInput(s.input); + s.input = null; + } + } + + /** + * Decodes a number in the range [0..255], by reading 1 - 11 bits. + */ + private static int decodeVarLenUnsignedByte(State s) { + BitReader.fillBitWindow(s); + if (BitReader.readFewBits(s, 1) != 0) { + final int n = BitReader.readFewBits(s, 3); + if (n == 0) { + return 1; + } else { + return BitReader.readFewBits(s, n) + (1 << n); + } + } + return 0; + } + + private static void decodeMetaBlockLength(State s) { + BitReader.fillBitWindow(s); + s.inputEnd = BitReader.readFewBits(s, 1); + s.metaBlockLength = 0; + s.isUncompressed = 0; + s.isMetadata = 0; + if ((s.inputEnd != 0) && BitReader.readFewBits(s, 1) != 0) { + return; + } + final int sizeNibbles = BitReader.readFewBits(s, 2) + 4; + if (sizeNibbles == 7) { + s.isMetadata = 1; + if (BitReader.readFewBits(s, 1) != 0) { + throw new BrotliRuntimeException("Corrupted reserved bit"); + } + final int sizeBytes = BitReader.readFewBits(s, 2); + if (sizeBytes == 0) { + return; + } + for (int i = 0; i < sizeBytes; i++) { + BitReader.fillBitWindow(s); + final int bits = BitReader.readFewBits(s, 8); + if (bits == 0 && i + 1 == sizeBytes && sizeBytes > 1) { + throw new BrotliRuntimeException("Exuberant nibble"); + } + s.metaBlockLength |= bits << (i * 8); + } + } else { + for (int i = 0; i < sizeNibbles; i++) { + BitReader.fillBitWindow(s); + final int bits = BitReader.readFewBits(s, 4); + if (bits == 0 && i + 1 == sizeNibbles && sizeNibbles > 4) { + throw new BrotliRuntimeException("Exuberant nibble"); + } + s.metaBlockLength |= bits << (i * 4); + } + } + s.metaBlockLength++; + if (s.inputEnd == 0) { + s.isUncompressed = BitReader.readFewBits(s, 1); + } + } + + /** + * Decodes the next Huffman code from bit-stream. + */ + private static int readSymbol(int[] tableGroup, int tableIdx, State s) { + int offset = tableGroup[tableIdx]; + final int val = BitReader.peekBits(s); + offset += val & HUFFMAN_TABLE_MASK; + final int bits = tableGroup[offset] >> 16; + final int sym = tableGroup[offset] & 0xFFFF; + if (bits <= HUFFMAN_TABLE_BITS) { + s.bitOffset += bits; + return sym; + } + offset += sym; + final int mask = (1 << bits) - 1; + offset += (val & mask) >>> HUFFMAN_TABLE_BITS; + s.bitOffset += ((tableGroup[offset] >> 16) + HUFFMAN_TABLE_BITS); + return tableGroup[offset] & 0xFFFF; + } + + private static int readBlockLength(int[] tableGroup, int tableIdx, State s) { + BitReader.fillBitWindow(s); + final int code = readSymbol(tableGroup, tableIdx, s); + final int n = BLOCK_LENGTH_N_BITS[code]; + BitReader.fillBitWindow(s); + return BLOCK_LENGTH_OFFSET[code] + BitReader.readBits(s, n); + } + + private static void moveToFront(int[] v, int index) { + final int value = v[index]; + for (; index > 0; index--) { + v[index] = v[index - 1]; + } + v[0] = value; + } + + private static void inverseMoveToFrontTransform(byte[] v, int vLen) { + final int[] mtf = new int[256]; + for (int i = 0; i < 256; i++) { + mtf[i] = i; + } + for (int i = 0; i < vLen; i++) { + final int index = v[i] & 0xFF; + v[i] = (byte) mtf[index]; + if (index != 0) { + moveToFront(mtf, index); + } + } + } + + private static void readHuffmanCodeLengths( + int[] codeLengthCodeLengths, int numSymbols, int[] codeLengths, State s) { + int symbol = 0; + int prevCodeLen = DEFAULT_CODE_LENGTH; + int repeat = 0; + int repeatCodeLen = 0; + int space = 32768; + final int[] table = new int[32 + 1]; /* Speculative single entry table group. */ + final int tableIdx = table.length - 1; + Huffman.buildHuffmanTable(table, tableIdx, 5, codeLengthCodeLengths, CODE_LENGTH_CODES); + + while (symbol < numSymbols && space > 0) { + BitReader.readMoreInput(s); + BitReader.fillBitWindow(s); + final int p = BitReader.peekBits(s) & 31; + s.bitOffset += table[p] >> 16; + final int codeLen = table[p] & 0xFFFF; + if (codeLen < CODE_LENGTH_REPEAT_CODE) { + repeat = 0; + codeLengths[symbol++] = codeLen; + if (codeLen != 0) { + prevCodeLen = codeLen; + space -= 32768 >> codeLen; + } + } else { + final int extraBits = codeLen - 14; + int newLen = 0; + if (codeLen == CODE_LENGTH_REPEAT_CODE) { + newLen = prevCodeLen; + } + if (repeatCodeLen != newLen) { + repeat = 0; + repeatCodeLen = newLen; + } + final int oldRepeat = repeat; + if (repeat > 0) { + repeat -= 2; + repeat <<= extraBits; + } + BitReader.fillBitWindow(s); + repeat += BitReader.readFewBits(s, extraBits) + 3; + final int repeatDelta = repeat - oldRepeat; + if (symbol + repeatDelta > numSymbols) { + throw new BrotliRuntimeException("symbol + repeatDelta > numSymbols"); // COV_NF_LINE + } + for (int i = 0; i < repeatDelta; i++) { + codeLengths[symbol++] = repeatCodeLen; + } + if (repeatCodeLen != 0) { + space -= repeatDelta << (15 - repeatCodeLen); + } + } + } + if (space != 0) { + throw new BrotliRuntimeException("Unused space"); // COV_NF_LINE + } + // TODO(eustas): Pass max_symbol to Huffman table builder instead? + Utils.fillIntsWithZeroes(codeLengths, symbol, numSymbols); + } + + private static void checkDupes(int[] symbols, int length) { + for (int i = 0; i < length - 1; ++i) { + for (int j = i + 1; j < length; ++j) { + if (symbols[i] == symbols[j]) { + throw new BrotliRuntimeException("Duplicate simple Huffman code symbol"); // COV_NF_LINE + } + } + } + } + + /** + * Reads up to 4 symbols directly and applies predefined histograms. + */ + private static int readSimpleHuffmanCode(int alphabetSizeMax, int alphabetSizeLimit, + int[] tableGroup, int tableIdx, State s) { + // TODO(eustas): Avoid allocation? + final int[] codeLengths = new int[alphabetSizeLimit]; + final int[] symbols = new int[4]; + + final int maxBits = 1 + log2floor(alphabetSizeMax - 1); + + final int numSymbols = BitReader.readFewBits(s, 2) + 1; + for (int i = 0; i < numSymbols; i++) { + BitReader.fillBitWindow(s); + final int symbol = BitReader.readFewBits(s, maxBits); + if (symbol >= alphabetSizeLimit) { + throw new BrotliRuntimeException("Can't readHuffmanCode"); // COV_NF_LINE + } + symbols[i] = symbol; + } + checkDupes(symbols, numSymbols); + + int histogramId = numSymbols; + if (numSymbols == 4) { + histogramId += BitReader.readFewBits(s, 1); + } + + switch (histogramId) { + case 1: + codeLengths[symbols[0]] = 1; + break; + + case 2: + codeLengths[symbols[0]] = 1; + codeLengths[symbols[1]] = 1; + break; + + case 3: + codeLengths[symbols[0]] = 1; + codeLengths[symbols[1]] = 2; + codeLengths[symbols[2]] = 2; + break; + + case 4: // uniform 4-symbol histogram + codeLengths[symbols[0]] = 2; + codeLengths[symbols[1]] = 2; + codeLengths[symbols[2]] = 2; + codeLengths[symbols[3]] = 2; + break; + + case 5: // prioritized 4-symbol histogram + codeLengths[symbols[0]] = 1; + codeLengths[symbols[1]] = 2; + codeLengths[symbols[2]] = 3; + codeLengths[symbols[3]] = 3; + break; + + default: + break; + } + + // TODO(eustas): Use specialized version? + return Huffman.buildHuffmanTable( + tableGroup, tableIdx, HUFFMAN_TABLE_BITS, codeLengths, alphabetSizeLimit); + } + + // Decode Huffman-coded code lengths. + private static int readComplexHuffmanCode(int alphabetSizeLimit, int skip, + int[] tableGroup, int tableIdx, State s) { + // TODO(eustas): Avoid allocation? + final int[] codeLengths = new int[alphabetSizeLimit]; + final int[] codeLengthCodeLengths = new int[CODE_LENGTH_CODES]; + int space = 32; + int numCodes = 0; + for (int i = skip; i < CODE_LENGTH_CODES && space > 0; i++) { + final int codeLenIdx = CODE_LENGTH_CODE_ORDER[i]; + BitReader.fillBitWindow(s); + final int p = BitReader.peekBits(s) & 15; + // TODO(eustas): Demultiplex FIXED_TABLE. + s.bitOffset += FIXED_TABLE[p] >> 16; + final int v = FIXED_TABLE[p] & 0xFFFF; + codeLengthCodeLengths[codeLenIdx] = v; + if (v != 0) { + space -= (32 >> v); + numCodes++; + } + } + if (space != 0 && numCodes != 1) { + throw new BrotliRuntimeException("Corrupted Huffman code histogram"); // COV_NF_LINE + } + + readHuffmanCodeLengths(codeLengthCodeLengths, alphabetSizeLimit, codeLengths, s); + + return Huffman.buildHuffmanTable( + tableGroup, tableIdx, HUFFMAN_TABLE_BITS, codeLengths, alphabetSizeLimit); + } + + /** + * Decodes Huffman table from bit-stream. + * + * @return number of slots used by resulting Huffman table + */ + private static int readHuffmanCode(int alphabetSizeMax, int alphabetSizeLimit, + int[] tableGroup, int tableIdx, State s) { + BitReader.readMoreInput(s); + BitReader.fillBitWindow(s); + final int simpleCodeOrSkip = BitReader.readFewBits(s, 2); + if (simpleCodeOrSkip == 1) { + return readSimpleHuffmanCode(alphabetSizeMax, alphabetSizeLimit, tableGroup, tableIdx, s); + } else { + return readComplexHuffmanCode(alphabetSizeLimit, simpleCodeOrSkip, tableGroup, tableIdx, s); + } + } + + private static int decodeContextMap(int contextMapSize, byte[] contextMap, State s) { + BitReader.readMoreInput(s); + final int numTrees = decodeVarLenUnsignedByte(s) + 1; + + if (numTrees == 1) { + Utils.fillBytesWithZeroes(contextMap, 0, contextMapSize); + return numTrees; + } + + BitReader.fillBitWindow(s); + final int useRleForZeros = BitReader.readFewBits(s, 1); + int maxRunLengthPrefix = 0; + if (useRleForZeros != 0) { + maxRunLengthPrefix = BitReader.readFewBits(s, 4) + 1; + } + final int alphabetSize = numTrees + maxRunLengthPrefix; + final int tableSize = MAX_HUFFMAN_TABLE_SIZE[(alphabetSize + 31) >> 5]; + /* Speculative single entry table group. */ + final int[] table = new int[tableSize + 1]; + final int tableIdx = table.length - 1; + readHuffmanCode(alphabetSize, alphabetSize, table, tableIdx, s); + for (int i = 0; i < contextMapSize; ) { + BitReader.readMoreInput(s); + BitReader.fillBitWindow(s); + final int code = readSymbol(table, tableIdx, s); + if (code == 0) { + contextMap[i] = 0; + i++; + } else if (code <= maxRunLengthPrefix) { + BitReader.fillBitWindow(s); + int reps = (1 << code) + BitReader.readFewBits(s, code); + while (reps != 0) { + if (i >= contextMapSize) { + throw new BrotliRuntimeException("Corrupted context map"); // COV_NF_LINE + } + contextMap[i] = 0; + i++; + reps--; + } + } else { + contextMap[i] = (byte) (code - maxRunLengthPrefix); + i++; + } + } + BitReader.fillBitWindow(s); + if (BitReader.readFewBits(s, 1) == 1) { + inverseMoveToFrontTransform(contextMap, contextMapSize); + } + return numTrees; + } + + private static int decodeBlockTypeAndLength(State s, int treeType, int numBlockTypes) { + final int[] ringBuffers = s.rings; + final int offset = 4 + treeType * 2; + BitReader.fillBitWindow(s); + int blockType = readSymbol(s.blockTrees, 2 * treeType, s); + final int result = readBlockLength(s.blockTrees, 2 * treeType + 1, s); + + if (blockType == 1) { + blockType = ringBuffers[offset + 1] + 1; + } else if (blockType == 0) { + blockType = ringBuffers[offset]; + } else { + blockType -= 2; + } + if (blockType >= numBlockTypes) { + blockType -= numBlockTypes; + } + ringBuffers[offset] = ringBuffers[offset + 1]; + ringBuffers[offset + 1] = blockType; + return result; + } + + private static void decodeLiteralBlockSwitch(State s) { + s.literalBlockLength = decodeBlockTypeAndLength(s, 0, s.numLiteralBlockTypes); + final int literalBlockType = s.rings[5]; + s.contextMapSlice = literalBlockType << LITERAL_CONTEXT_BITS; + s.literalTreeIdx = s.contextMap[s.contextMapSlice] & 0xFF; + final int contextMode = s.contextModes[literalBlockType]; + s.contextLookupOffset1 = contextMode << 9; + s.contextLookupOffset2 = s.contextLookupOffset1 + 256; + } + + private static void decodeCommandBlockSwitch(State s) { + s.commandBlockLength = decodeBlockTypeAndLength(s, 1, s.numCommandBlockTypes); + s.commandTreeIdx = s.rings[7]; + } + + private static void decodeDistanceBlockSwitch(State s) { + s.distanceBlockLength = decodeBlockTypeAndLength(s, 2, s.numDistanceBlockTypes); + s.distContextMapSlice = s.rings[9] << DISTANCE_CONTEXT_BITS; + } + + private static void maybeReallocateRingBuffer(State s) { + int newSize = s.maxRingBufferSize; + if (newSize > s.expectedTotalSize) { + /* TODO(eustas): Handle 2GB+ cases more gracefully. */ + final int minimalNewSize = s.expectedTotalSize; + while ((newSize >> 1) > minimalNewSize) { + newSize >>= 1; + } + if ((s.inputEnd == 0) && newSize < 16384 && s.maxRingBufferSize >= 16384) { + newSize = 16384; + } + } + if (newSize <= s.ringBufferSize) { + return; + } + final int ringBufferSizeWithSlack = newSize + MAX_TRANSFORMED_WORD_LENGTH; + final byte[] newBuffer = new byte[ringBufferSizeWithSlack]; + if (s.ringBuffer.length != 0) { + System.arraycopy(s.ringBuffer, 0, newBuffer, 0, s.ringBufferSize); + } + s.ringBuffer = newBuffer; + s.ringBufferSize = newSize; + } + + private static void readNextMetablockHeader(State s) { + if (s.inputEnd != 0) { + s.nextRunningState = FINISHED; + s.runningState = INIT_WRITE; + return; + } + // TODO(eustas): Reset? Do we need this? + s.literalTreeGroup = new int[0]; + s.commandTreeGroup = new int[0]; + s.distanceTreeGroup = new int[0]; + + BitReader.readMoreInput(s); + decodeMetaBlockLength(s); + if ((s.metaBlockLength == 0) && (s.isMetadata == 0)) { + return; + } + if ((s.isUncompressed != 0) || (s.isMetadata != 0)) { + BitReader.jumpToByteBoundary(s); + s.runningState = (s.isMetadata != 0) ? READ_METADATA : COPY_UNCOMPRESSED; + } else { + s.runningState = COMPRESSED_BLOCK_START; + } + + if (s.isMetadata != 0) { + return; + } + s.expectedTotalSize += s.metaBlockLength; + if (s.expectedTotalSize > 1 << 30) { + s.expectedTotalSize = 1 << 30; + } + if (s.ringBufferSize < s.maxRingBufferSize) { + maybeReallocateRingBuffer(s); + } + } + + private static int readMetablockPartition(State s, int treeType, int numBlockTypes) { + int offset = s.blockTrees[2 * treeType]; + if (numBlockTypes <= 1) { + s.blockTrees[2 * treeType + 1] = offset; + s.blockTrees[2 * treeType + 2] = offset; + return 1 << 28; + } + + final int blockTypeAlphabetSize = numBlockTypes + 2; + offset += readHuffmanCode( + blockTypeAlphabetSize, blockTypeAlphabetSize, s.blockTrees, 2 * treeType, s); + s.blockTrees[2 * treeType + 1] = offset; + + final int blockLengthAlphabetSize = NUM_BLOCK_LENGTH_CODES; + offset += readHuffmanCode( + blockLengthAlphabetSize, blockLengthAlphabetSize, s.blockTrees, 2 * treeType + 1, s); + s.blockTrees[2 * treeType + 2] = offset; + + return readBlockLength(s.blockTrees, 2 * treeType + 1, s); + } + + private static void calculateDistanceLut(State s, int alphabetSizeLimit) { + final byte[] distExtraBits = s.distExtraBits; + final int[] distOffset = s.distOffset; + final int npostfix = s.distancePostfixBits; + final int ndirect = s.numDirectDistanceCodes; + final int postfix = 1 << npostfix; + int bits = 1; + int half = 0; + + /* Skip short codes. */ + int i = NUM_DISTANCE_SHORT_CODES; + + /* Fill direct codes. */ + for (int j = 0; j < ndirect; ++j) { + distExtraBits[i] = 0; + distOffset[i] = j + 1; + ++i; + } + + /* Fill regular distance codes. */ + while (i < alphabetSizeLimit) { + final int base = ndirect + ((((2 + half) << bits) - 4) << npostfix) + 1; + /* Always fill the complete group. */ + for (int j = 0; j < postfix; ++j) { + distExtraBits[i] = (byte) bits; + distOffset[i] = base + j; + ++i; + } + bits = bits + half; + half = half ^ 1; + } + } + + private static void readMetablockHuffmanCodesAndContextMaps(State s) { + s.numLiteralBlockTypes = decodeVarLenUnsignedByte(s) + 1; + s.literalBlockLength = readMetablockPartition(s, 0, s.numLiteralBlockTypes); + s.numCommandBlockTypes = decodeVarLenUnsignedByte(s) + 1; + s.commandBlockLength = readMetablockPartition(s, 1, s.numCommandBlockTypes); + s.numDistanceBlockTypes = decodeVarLenUnsignedByte(s) + 1; + s.distanceBlockLength = readMetablockPartition(s, 2, s.numDistanceBlockTypes); + + BitReader.readMoreInput(s); + BitReader.fillBitWindow(s); + s.distancePostfixBits = BitReader.readFewBits(s, 2); + s.numDirectDistanceCodes = BitReader.readFewBits(s, 4) << s.distancePostfixBits; + // TODO(eustas): Reuse? + s.contextModes = new byte[s.numLiteralBlockTypes]; + for (int i = 0; i < s.numLiteralBlockTypes;) { + /* Ensure that less than 256 bits read between readMoreInput. */ + final int limit = Math.min(i + 96, s.numLiteralBlockTypes); + for (; i < limit; ++i) { + BitReader.fillBitWindow(s); + s.contextModes[i] = (byte) BitReader.readFewBits(s, 2); + } + BitReader.readMoreInput(s); + } + + // TODO(eustas): Reuse? + s.contextMap = new byte[s.numLiteralBlockTypes << LITERAL_CONTEXT_BITS]; + final int numLiteralTrees = decodeContextMap(s.numLiteralBlockTypes << LITERAL_CONTEXT_BITS, + s.contextMap, s); + s.trivialLiteralContext = 1; + for (int j = 0; j < s.numLiteralBlockTypes << LITERAL_CONTEXT_BITS; j++) { + if (s.contextMap[j] != j >> LITERAL_CONTEXT_BITS) { + s.trivialLiteralContext = 0; + break; + } + } + + // TODO(eustas): Reuse? + s.distContextMap = new byte[s.numDistanceBlockTypes << DISTANCE_CONTEXT_BITS]; + final int numDistTrees = decodeContextMap(s.numDistanceBlockTypes << DISTANCE_CONTEXT_BITS, + s.distContextMap, s); + + s.literalTreeGroup = decodeHuffmanTreeGroup(NUM_LITERAL_CODES, NUM_LITERAL_CODES, + numLiteralTrees, s); + s.commandTreeGroup = decodeHuffmanTreeGroup(NUM_COMMAND_CODES, NUM_COMMAND_CODES, + s.numCommandBlockTypes, s); + int distanceAlphabetSizeMax = calculateDistanceAlphabetSize( + s.distancePostfixBits, s.numDirectDistanceCodes, MAX_DISTANCE_BITS); + int distanceAlphabetSizeLimit = distanceAlphabetSizeMax; + if (s.isLargeWindow == 1) { + distanceAlphabetSizeMax = calculateDistanceAlphabetSize( + s.distancePostfixBits, s.numDirectDistanceCodes, MAX_LARGE_WINDOW_DISTANCE_BITS); + distanceAlphabetSizeLimit = calculateDistanceAlphabetLimit( + MAX_ALLOWED_DISTANCE, s.distancePostfixBits, s.numDirectDistanceCodes); + } + s.distanceTreeGroup = decodeHuffmanTreeGroup(distanceAlphabetSizeMax, distanceAlphabetSizeLimit, + numDistTrees, s); + calculateDistanceLut(s, distanceAlphabetSizeLimit); + + s.contextMapSlice = 0; + s.distContextMapSlice = 0; + s.contextLookupOffset1 = s.contextModes[0] * 512; + s.contextLookupOffset2 = s.contextLookupOffset1 + 256; + s.literalTreeIdx = 0; + s.commandTreeIdx = 0; + + s.rings[4] = 1; + s.rings[5] = 0; + s.rings[6] = 1; + s.rings[7] = 0; + s.rings[8] = 1; + s.rings[9] = 0; + } + + private static void copyUncompressedData(State s) { + final byte[] ringBuffer = s.ringBuffer; + + // Could happen if block ends at ring buffer end. + if (s.metaBlockLength <= 0) { + BitReader.reload(s); + s.runningState = BLOCK_START; + return; + } + + final int chunkLength = Math.min(s.ringBufferSize - s.pos, s.metaBlockLength); + BitReader.copyRawBytes(s, ringBuffer, s.pos, chunkLength); + s.metaBlockLength -= chunkLength; + s.pos += chunkLength; + if (s.pos == s.ringBufferSize) { + s.nextRunningState = COPY_UNCOMPRESSED; + s.runningState = INIT_WRITE; + return; + } + + BitReader.reload(s); + s.runningState = BLOCK_START; + } + + private static int writeRingBuffer(State s) { + final int toWrite = Math.min(s.outputLength - s.outputUsed, + s.ringBufferBytesReady - s.ringBufferBytesWritten); + // TODO(eustas): DCHECK(toWrite >= 0) + if (toWrite != 0) { + System.arraycopy(s.ringBuffer, s.ringBufferBytesWritten, s.output, + s.outputOffset + s.outputUsed, toWrite); + s.outputUsed += toWrite; + s.ringBufferBytesWritten += toWrite; + } + + if (s.outputUsed < s.outputLength) { + return 1; + } else { + return 0; + } + } + + private static int[] decodeHuffmanTreeGroup(int alphabetSizeMax, int alphabetSizeLimit, + int n, State s) { + final int maxTableSize = MAX_HUFFMAN_TABLE_SIZE[(alphabetSizeLimit + 31) >> 5]; + final int[] group = new int[n + n * maxTableSize]; + int next = n; + for (int i = 0; i < n; ++i) { + group[i] = next; + next += readHuffmanCode(alphabetSizeMax, alphabetSizeLimit, group, i, s); + } + return group; + } + + // Returns offset in ringBuffer that should trigger WRITE when filled. + private static int calculateFence(State s) { + int result = s.ringBufferSize; + if (s.isEager != 0) { + result = Math.min(result, s.ringBufferBytesWritten + s.outputLength - s.outputUsed); + } + return result; + } + + private static void doUseDictionary(State s, int fence) { + if (s.distance > MAX_ALLOWED_DISTANCE) { + throw new BrotliRuntimeException("Invalid backward reference"); + } + final int address = s.distance - s.maxDistance - 1 - s.cdTotalSize; + if (address < 0) { + initializeCompoundDictionaryCopy(s, -address - 1, s.copyLength); + s.runningState = COPY_FROM_COMPOUND_DICTIONARY; + } else { + // Force lazy dictionary initialization. + final ByteBuffer dictionaryData = Dictionary.getData(); + final int wordLength = s.copyLength; + if (wordLength > Dictionary.MAX_DICTIONARY_WORD_LENGTH) { + throw new BrotliRuntimeException("Invalid backward reference"); // COV_NF_LINE + } + final int shift = Dictionary.sizeBits[wordLength]; + if (shift == 0) { + throw new BrotliRuntimeException("Invalid backward reference"); // COV_NF_LINE + } + int offset = Dictionary.offsets[wordLength]; + final int mask = (1 << shift) - 1; + final int wordIdx = address & mask; + final int transformIdx = address >>> shift; + offset += wordIdx * wordLength; + final Transform.Transforms transforms = Transform.RFC_TRANSFORMS; + if (transformIdx >= transforms.numTransforms) { + throw new BrotliRuntimeException("Invalid backward reference"); // COV_NF_LINE + } + final int len = Transform.transformDictionaryWord(s.ringBuffer, s.pos, dictionaryData, + offset, wordLength, transforms, transformIdx); + s.pos += len; + s.metaBlockLength -= len; + if (s.pos >= fence) { + s.nextRunningState = MAIN_LOOP; + s.runningState = INIT_WRITE; + return; + } + s.runningState = MAIN_LOOP; + } + } + + private static void initializeCompoundDictionary(State s) { + s.cdBlockMap = new byte[1 << CD_BLOCK_MAP_BITS]; + int blockBits = CD_BLOCK_MAP_BITS; + // If this function is executed, then s.cdTotalSize > 0. + while (((s.cdTotalSize - 1) >>> blockBits) != 0) { + blockBits++; + } + blockBits -= CD_BLOCK_MAP_BITS; + s.cdBlockBits = blockBits; + int cursor = 0; + int index = 0; + while (cursor < s.cdTotalSize) { + while (s.cdChunkOffsets[index + 1] < cursor) { + index++; + } + s.cdBlockMap[cursor >>> blockBits] = (byte) index; + cursor += 1 << blockBits; + } + } + + private static void initializeCompoundDictionaryCopy(State s, int address, int length) { + if (s.cdBlockBits == -1) { + initializeCompoundDictionary(s); + } + int index = s.cdBlockMap[address >>> s.cdBlockBits]; + while (address >= s.cdChunkOffsets[index + 1]) { + index++; + } + if (s.cdTotalSize > address + length) { + throw new BrotliRuntimeException("Invalid backward reference"); + } + /* Update the recent distances cache */ + s.distRbIdx = (s.distRbIdx + 1) & 0x3; + s.rings[s.distRbIdx] = s.distance; + s.metaBlockLength -= length; + s.cdBrIndex = index; + s.cdBrOffset = address - s.cdChunkOffsets[index]; + s.cdBrLength = length; + s.cdBrCopied = 0; + } + + private static int copyFromCompoundDictionary(State s, int fence) { + int pos = s.pos; + final int origPos = pos; + while (s.cdBrLength != s.cdBrCopied) { + final int space = fence - pos; + final int chunkLength = s.cdChunkOffsets[s.cdBrIndex + 1] - s.cdChunkOffsets[s.cdBrIndex]; + final int remChunkLength = chunkLength - s.cdBrOffset; + int length = s.cdBrLength - s.cdBrCopied; + if (length > remChunkLength) { + length = remChunkLength; + } + if (length > space) { + length = space; + } + Utils.copyBytes( + s.ringBuffer, pos, s.cdChunks[s.cdBrIndex], s.cdBrOffset, s.cdBrOffset + length); + pos += length; + s.cdBrOffset += length; + s.cdBrCopied += length; + if (length == remChunkLength) { + s.cdBrIndex++; + s.cdBrOffset = 0; + } + if (pos >= fence) { + break; + } + } + return pos - origPos; + } + + /** + * Actual decompress implementation. + */ + static void decompress(State s) { + if (s.runningState == UNINITIALIZED) { + throw new IllegalStateException("Can't decompress until initialized"); + } + if (s.runningState == CLOSED) { + throw new IllegalStateException("Can't decompress after close"); + } + if (s.runningState == INITIALIZED) { + final int windowBits = decodeWindowBits(s); + if (windowBits == -1) { /* Reserved case for future expansion. */ + throw new BrotliRuntimeException("Invalid 'windowBits' code"); + } + s.maxRingBufferSize = 1 << windowBits; + s.maxBackwardDistance = s.maxRingBufferSize - 16; + s.runningState = BLOCK_START; + } + + int fence = calculateFence(s); + int ringBufferMask = s.ringBufferSize - 1; + byte[] ringBuffer = s.ringBuffer; + + while (s.runningState != FINISHED) { + // TODO(eustas): extract cases to methods for the better readability. + switch (s.runningState) { + case BLOCK_START: + if (s.metaBlockLength < 0) { + throw new BrotliRuntimeException("Invalid metablock length"); + } + readNextMetablockHeader(s); + /* Ring-buffer would be reallocated here. */ + fence = calculateFence(s); + ringBufferMask = s.ringBufferSize - 1; + ringBuffer = s.ringBuffer; + continue; + + case COMPRESSED_BLOCK_START: + readMetablockHuffmanCodesAndContextMaps(s); + s.runningState = MAIN_LOOP; + + // fall through + case MAIN_LOOP: + if (s.metaBlockLength <= 0) { + s.runningState = BLOCK_START; + continue; + } + BitReader.readMoreInput(s); + if (s.commandBlockLength == 0) { + decodeCommandBlockSwitch(s); + } + s.commandBlockLength--; + BitReader.fillBitWindow(s); + final int cmdCode = readSymbol(s.commandTreeGroup, s.commandTreeIdx, s) << 2; + final short insertAndCopyExtraBits = CMD_LOOKUP[cmdCode]; + final int insertLengthOffset = CMD_LOOKUP[cmdCode + 1]; + final int copyLengthOffset = CMD_LOOKUP[cmdCode + 2]; + s.distanceCode = CMD_LOOKUP[cmdCode + 3]; + BitReader.fillBitWindow(s); + { + final int insertLengthExtraBits = insertAndCopyExtraBits & 0xFF; + s.insertLength = insertLengthOffset + BitReader.readBits(s, insertLengthExtraBits); + } + BitReader.fillBitWindow(s); + { + final int copyLengthExtraBits = insertAndCopyExtraBits >> 8; + s.copyLength = copyLengthOffset + BitReader.readBits(s, copyLengthExtraBits); + } + + s.j = 0; + s.runningState = INSERT_LOOP; + + // fall through + case INSERT_LOOP: + if (s.trivialLiteralContext != 0) { + while (s.j < s.insertLength) { + BitReader.readMoreInput(s); + if (s.literalBlockLength == 0) { + decodeLiteralBlockSwitch(s); + } + s.literalBlockLength--; + BitReader.fillBitWindow(s); + ringBuffer[s.pos] = (byte) readSymbol(s.literalTreeGroup, s.literalTreeIdx, s); + s.pos++; + s.j++; + if (s.pos >= fence) { + s.nextRunningState = INSERT_LOOP; + s.runningState = INIT_WRITE; + break; + } + } + } else { + int prevByte1 = ringBuffer[(s.pos - 1) & ringBufferMask] & 0xFF; + int prevByte2 = ringBuffer[(s.pos - 2) & ringBufferMask] & 0xFF; + while (s.j < s.insertLength) { + BitReader.readMoreInput(s); + if (s.literalBlockLength == 0) { + decodeLiteralBlockSwitch(s); + } + final int literalContext = Context.LOOKUP[s.contextLookupOffset1 + prevByte1] + | Context.LOOKUP[s.contextLookupOffset2 + prevByte2]; + final int literalTreeIdx = s.contextMap[s.contextMapSlice + literalContext] & 0xFF; + s.literalBlockLength--; + prevByte2 = prevByte1; + BitReader.fillBitWindow(s); + prevByte1 = readSymbol(s.literalTreeGroup, literalTreeIdx, s); + ringBuffer[s.pos] = (byte) prevByte1; + s.pos++; + s.j++; + if (s.pos >= fence) { + s.nextRunningState = INSERT_LOOP; + s.runningState = INIT_WRITE; + break; + } + } + } + if (s.runningState != INSERT_LOOP) { + continue; + } + s.metaBlockLength -= s.insertLength; + if (s.metaBlockLength <= 0) { + s.runningState = MAIN_LOOP; + continue; + } + int distanceCode = s.distanceCode; + if (distanceCode < 0) { + // distanceCode in untouched; assigning it 0 won't affect distance ring buffer rolling. + s.distance = s.rings[s.distRbIdx]; + } else { + BitReader.readMoreInput(s); + if (s.distanceBlockLength == 0) { + decodeDistanceBlockSwitch(s); + } + s.distanceBlockLength--; + BitReader.fillBitWindow(s); + final int distTreeIdx = s.distContextMap[s.distContextMapSlice + distanceCode] & 0xFF; + distanceCode = readSymbol(s.distanceTreeGroup, distTreeIdx, s); + if (distanceCode < NUM_DISTANCE_SHORT_CODES) { + final int index = + (s.distRbIdx + DISTANCE_SHORT_CODE_INDEX_OFFSET[distanceCode]) & 0x3; + s.distance = s.rings[index] + DISTANCE_SHORT_CODE_VALUE_OFFSET[distanceCode]; + if (s.distance < 0) { + throw new BrotliRuntimeException("Negative distance"); // COV_NF_LINE + } + } else { + final int extraBits = s.distExtraBits[distanceCode]; + int bits; + if (s.bitOffset + extraBits <= BitReader.BITNESS) { + bits = BitReader.readFewBits(s, extraBits); + } else { + BitReader.fillBitWindow(s); + bits = BitReader.readBits(s, extraBits); + } + s.distance = s.distOffset[distanceCode] + (bits << s.distancePostfixBits); + } + } + + if (s.maxDistance != s.maxBackwardDistance + && s.pos < s.maxBackwardDistance) { + s.maxDistance = s.pos; + } else { + s.maxDistance = s.maxBackwardDistance; + } + + if (s.distance > s.maxDistance) { + s.runningState = USE_DICTIONARY; + continue; + } + + if (distanceCode > 0) { + s.distRbIdx = (s.distRbIdx + 1) & 0x3; + s.rings[s.distRbIdx] = s.distance; + } + + if (s.copyLength > s.metaBlockLength) { + throw new BrotliRuntimeException("Invalid backward reference"); // COV_NF_LINE + } + s.j = 0; + s.runningState = COPY_LOOP; + + // fall through + case COPY_LOOP: + int src = (s.pos - s.distance) & ringBufferMask; + int dst = s.pos; + final int copyLength = s.copyLength - s.j; + final int srcEnd = src + copyLength; + final int dstEnd = dst + copyLength; + if ((srcEnd < ringBufferMask) && (dstEnd < ringBufferMask)) { + if (copyLength < 12 || (srcEnd > dst && dstEnd > src)) { + for (int k = 0; k < copyLength; k += 4) { + ringBuffer[dst++] = ringBuffer[src++]; + ringBuffer[dst++] = ringBuffer[src++]; + ringBuffer[dst++] = ringBuffer[src++]; + ringBuffer[dst++] = ringBuffer[src++]; + } + } else { + Utils.copyBytesWithin(ringBuffer, dst, src, srcEnd); + } + s.j += copyLength; + s.metaBlockLength -= copyLength; + s.pos += copyLength; + } else { + for (; s.j < s.copyLength;) { + ringBuffer[s.pos] = + ringBuffer[(s.pos - s.distance) & ringBufferMask]; + s.metaBlockLength--; + s.pos++; + s.j++; + if (s.pos >= fence) { + s.nextRunningState = COPY_LOOP; + s.runningState = INIT_WRITE; + break; + } + } + } + if (s.runningState == COPY_LOOP) { + s.runningState = MAIN_LOOP; + } + continue; + + case USE_DICTIONARY: + doUseDictionary(s, fence); + continue; + + case COPY_FROM_COMPOUND_DICTIONARY: + s.pos += copyFromCompoundDictionary(s, fence); + if (s.pos >= fence) { + s.nextRunningState = COPY_FROM_COMPOUND_DICTIONARY; + s.runningState = INIT_WRITE; + return; + } + s.runningState = MAIN_LOOP; + continue; + + case READ_METADATA: + while (s.metaBlockLength > 0) { + BitReader.readMoreInput(s); + // Optimize + BitReader.fillBitWindow(s); + BitReader.readFewBits(s, 8); + s.metaBlockLength--; + } + s.runningState = BLOCK_START; + continue; + + case COPY_UNCOMPRESSED: + copyUncompressedData(s); + continue; + + case INIT_WRITE: + s.ringBufferBytesReady = Math.min(s.pos, s.ringBufferSize); + s.runningState = WRITE; + + // fall through + case WRITE: + if (writeRingBuffer(s) == 0) { + // Output buffer is full. + return; + } + if (s.pos >= s.maxBackwardDistance) { + s.maxDistance = s.maxBackwardDistance; + } + // Wrap the ringBuffer. + if (s.pos >= s.ringBufferSize) { + if (s.pos > s.ringBufferSize) { + Utils.copyBytesWithin(ringBuffer, 0, s.ringBufferSize, s.pos); + } + s.pos &= ringBufferMask; + s.ringBufferBytesWritten = 0; + } + s.runningState = s.nextRunningState; + continue; + + default: + throw new BrotliRuntimeException("Unexpected state " + String.valueOf(s.runningState)); + } + } + if (s.runningState == FINISHED) { + if (s.metaBlockLength < 0) { + throw new BrotliRuntimeException("Invalid metablock length"); + } + BitReader.jumpToByteBoundary(s); + BitReader.checkHealth(s, 1); + } + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/Decoder.java b/firka/android/app/src/main/java/org/brotli/dec/Decoder.java new file mode 100644 index 00000000..e33f5a9a --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/Decoder.java @@ -0,0 +1,72 @@ +package org.brotli.dec; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class Decoder { + private static long decodeBytes(InputStream input, OutputStream output, byte[] buffer) + throws IOException { + long totalOut = 0; + int readBytes; + BrotliInputStream in = new BrotliInputStream(input); + in.enableLargeWindow(); + try { + while ((readBytes = in.read(buffer)) >= 0) { + output.write(buffer, 0, readBytes); + totalOut += readBytes; + } + } finally { + in.close(); + } + return totalOut; + } + + private static void decompress(String fromPath, String toPath, byte[] buffer) throws IOException { + long start; + long bytesDecoded; + long end; + InputStream in = null; + OutputStream out = null; + try { + in = new FileInputStream(fromPath); + out = new FileOutputStream(toPath); + start = System.nanoTime(); + bytesDecoded = decodeBytes(in, out, buffer); + end = System.nanoTime(); + } finally { + if (in != null) { + in.close(); // Hopefully, does not throw exception. + } + if (out != null) { + out.close(); + } + } + + double timeDelta = (end - start) / 1000000000.0; + if (timeDelta <= 0) { + return; + } + double mbDecoded = bytesDecoded / (1024.0 * 1024.0); + System.out.println(mbDecoded / timeDelta + " MiB/s"); + } + + public static void main(String... args) throws IOException { + if (args.length != 2 && args.length != 3) { + System.out.println("Usage: decoder [repeat]"); + return; + } + + int repeat = 1; + if (args.length == 3) { + repeat = Integer.parseInt(args[2]); + } + + byte[] buffer = new byte[1024 * 1024]; + for (int i = 0; i < repeat; ++i) { + decompress(args[0], args[1], buffer); + } + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/Dictionary.java b/firka/android/app/src/main/java/org/brotli/dec/Dictionary.java new file mode 100644 index 00000000..c2258ad2 --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/Dictionary.java @@ -0,0 +1,94 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +import java.nio.ByteBuffer; + +/** + * Collection of static dictionary words. + * + *

Dictionary content is loaded from binary resource when {@link #getData()} is executed for the + * first time. Consequently, it saves memory and CPU in case dictionary is not required. + * + *

One possible drawback is that multiple threads that need dictionary data may be blocked (only + * once in each classworld). To avoid this, it is enough to call {@link #getData()} proactively. + */ +public final class Dictionary { + static final int MIN_DICTIONARY_WORD_LENGTH = 4; + static final int MAX_DICTIONARY_WORD_LENGTH = 31; + + private static ByteBuffer data = ByteBuffer.allocateDirect(0); + static final int[] offsets = new int[32]; + static final int[] sizeBits = new int[32]; + + private static class DataLoader { + static final boolean OK; + + static { + boolean ok = true; + try { + Class.forName(Dictionary.class.getPackage().getName() + ".DictionaryData"); + } catch (Throwable ex) { + ok = false; + } + OK = ok; + } + } + + public static void setData(ByteBuffer newData, int[] newSizeBits) { + if ((Utils.isDirect(newData) == 0) || (Utils.isReadOnly(newData) == 0)) { + throw new BrotliRuntimeException("newData must be a direct read-only byte buffer"); + } + // TODO: is that so? + if (newSizeBits.length > MAX_DICTIONARY_WORD_LENGTH) { + throw new BrotliRuntimeException( + "sizeBits length must be at most " + String.valueOf(MAX_DICTIONARY_WORD_LENGTH)); + } + for (int i = 0; i < MIN_DICTIONARY_WORD_LENGTH; ++i) { + if (newSizeBits[i] != 0) { + throw new BrotliRuntimeException( + "first " + String.valueOf(MIN_DICTIONARY_WORD_LENGTH) + " must be 0"); + } + } + final int[] dictionaryOffsets = Dictionary.offsets; + final int[] dictionarySizeBits = Dictionary.sizeBits; + System.arraycopy(newSizeBits, 0, dictionarySizeBits, 0, newSizeBits.length); + int pos = 0; + final int limit = newData.capacity(); + for (int i = 0; i < newSizeBits.length; ++i) { + dictionaryOffsets[i] = pos; + final int bits = dictionarySizeBits[i]; + if (bits != 0) { + if (bits >= 31) { + throw new BrotliRuntimeException("newSizeBits values must be less than 31"); + } + pos += i << bits; + if (pos <= 0 || pos > limit) { + throw new BrotliRuntimeException("newSizeBits is inconsistent: overflow"); + } + } + } + for (int i = newSizeBits.length; i < 32; ++i) { + dictionaryOffsets[i] = pos; + } + if (pos != limit) { + throw new BrotliRuntimeException("newSizeBits is inconsistent: underflow"); + } + Dictionary.data = newData; + } + + public static ByteBuffer getData() { + if (data.capacity() != 0) { + return data; + } + if (!DataLoader.OK) { + throw new BrotliRuntimeException("brotli dictionary is not set"); + } + /* Might have been set when {@link DictionaryData} was loaded.*/ + return data; + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/DictionaryData.java b/firka/android/app/src/main/java/org/brotli/dec/DictionaryData.java new file mode 100644 index 00000000..ad96f38e --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/DictionaryData.java @@ -0,0 +1,75 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +import java.nio.ByteBuffer; + +/** + * Built-in dictionary data. + * + * When this class is loaded, it sets its data: {@link Dictionary#setData(ByteBuffer)}. + */ +final class DictionaryData { + private static final String DATA0 = "wjnfgltmojefofewab`h`lgfgbwbpkltlmozpjwf`jwzlsfmivpwojhfeqfftlqhwf{wzfbqlufqalgzolufelqnallhsobzojufojmfkfosklnfpjgfnlqftlqgolmdwkfnujftejmgsbdfgbzpevookfbgwfqnfb`kbqfbeqlnwqvfnbqhbaofvslmkjdkgbwfobmgmftpfufmmf{w`bpfalwkslpwvpfgnbgfkbmgkfqftkbwmbnfOjmhaoldpjyfabpfkfognbhfnbjmvpfq$*#(klogfmgptjwkMftpqfbgtfqfpjdmwbhfkbufdbnfpffm`boosbwktfoosovpnfmvejonsbqwiljmwkjpojpwdllgmffgtbzptfpwilapnjmgboploldlqj`kvpfpobpwwfbnbqnzellghjmdtjoofbpwtbqgafpwejqfSbdfhmltbtbz-smdnlufwkbmolbgdjufpfoemlwfnv`keffgnbmzql`hj`lmlm`follhkjgfgjfgKlnfqvofklpwbib{jmel`ovaobtpofppkboeplnfpv`kylmf233&lmfp`bqfWjnfqb`faovfelvqtffheb`fklsfdbufkbqgolpwtkfmsbqhhfswsbpppkjsqllnKWNOsobmWzsfglmfpbufhffseobdojmhplogejufwllhqbwfwltmivnswkvpgbqh`bqgejofefbqpwbzhjoowkbweboobvwlfufq-`lnwbohpklsulwfgffsnlgfqfpwwvqmalqmabmgefooqlpfvqo+phjmqlof`lnfb`wpbdfpnffwdlog-isdjwfnubqzefowwkfmpfmggqlsUjft`lsz2-3!?,b=pwlsfopfojfpwlvqsb`h-djesbpw`pp!pfwp6s{8-ip<73s{je#+pllmpfbwmlmfwvafyfqlpfmwqffgeb`wjmwldjewkbqn2;s{`bnfkjooalogyllnuljgfbpzqjmdejoosfbhjmjw`lpw0s{8ib`hwbdpajwpqloofgjwhmftmfbq?\"..dqltIPLMgvwzMbnfpbofzlv#olwpsbjmibyy`logfzfpejpkttt-qjphwbapsqfu23s{qjpf16s{Aovfgjmd033/abooelqgfbqmtjogal{-ebjqob`hufqpsbjqivmfwf`kje+\"sj`hfujo'+! tbqnolqgglfpsvoo/333jgfbgqbtkvdfpslwevmgavqmkqfe`foohfzpwj`hklvqolppevfo21s{pvjwgfboQPP!bdfgdqfzDFW!fbpfbjnpdjqobjgp;s{8mbuzdqjgwjsp :::tbqpobgz`bqp*8#~sksolpfmvooubpwtjmgQPP#tfbqqfozaffmpbnfgvhfmbpb`bsftjpkdvoeW109kjwppolwdbwfhj`haovqwkfz26s{$$*8*8!=npjftjmpajqgplqwafwbpffhW2;9lqgpwqffnboo53s{ebqn\u000ElupalzpX3^-$*8!SLPWafbqhjgp*8~~nbqzwfmg+VH*rvbgyk9\n.pjy....sqls$*8\u000EojewW2:9uj`fbmgzgfaw=QPPsllomf`haoltW259gllqfuboW249ofwpebjolqbosloomlub`lopdfmf#\u000Elxplewqlnfwjooqlpp?k0=slvqebgfsjmh?wq=njmj*\u007F\"+njmfyk9\u0004abqpkfbq33*8njoh#..=jqlmeqfggjphtfmwpljosvwp,ip,klozW119JPAMW139bgbnpffp?k1=iplm$/#$`lmwW129#QPPollsbpjbnllm?,s=plvoOJMFelqw`bqwW279?k2=;3s{\"..?:s{8W379njhf975Ymj`fjm`kZlqhqj`fyk9\b$**8svqfnbdfsbqbwlmfalmg904Y\\le\\$^*8333/yk9\u000Bwbmhzbqgaltoavpk965YIbub03s{\t\u007F~\t&@0&907YifeeF[SJ`bpkujpbdloepmltyk9\u0005rvfq-`pppj`hnfbwnjm-ajmggfookjqfsj`pqfmw905YKWWS.132elwltloeFMG#{al{967YALGZgj`h8\t~\tf{jw906Yubqpafbw$~*8gjfw:::8bmmf~~?,Xj^-Obmdhn.^tjqfwlzpbggppfbobof{8\t\n~f`klmjmf-lqd336*wlmziftppbmgofdpqlle333*#133tjmfdfbqgldpallwdbqz`vwpwzofwfnswjlm-{no`l`hdbmd'+$-63s{Sk-Gnjp`bobmolbmgfphnjofqzbmvmj{gjp`*8~\tgvpw`ojs*-\t\t43s{.133GUGp4^=?wbsfgfnlj((*tbdffvqlskjolswpklofEBRpbpjm.15WobapsfwpVQO#avoh`llh8~\u000E\tKFBGX3^*baaqivbm+2:;ofpkwtjm?,j=plmzdvzpev`hsjsf\u007F.\t\"331*mgltX2^8X^8\tOld#pbow\u000E\t\n\nabmdwqjnabwk*x\u000E\t33s{\t~*8hl9\u0000effpbg=\u000Ep9,,#X^8wloosovd+*x\tx\u000E\t#-ip$133sgvboalbw-ISD*8\t~rvlw*8\t\t$*8\t\u000E\t~\u000E1327132613251324132;132:13131312131113101317131613151314131;131:130313021301130013071306130513041320132113221323133:133;133413351336133713301331133213332:::2::;2::42::52::62::72::02::12::22::32:;:2:;;2:;42:;52:;62:;72:;02:;12:;22:;32:4:2:4;2:442:452:462:472:402:412:422:432:5:2:5;2:542:552:562:572:502:512:522:532:6:2:6;2:642:652:662:672:602:612:622:632333231720:73333::::`lnln/Mpfpwffpwbsfqlwlglkb`f`bgbb/]lajfmg/Abbp/Aujgb`bpllwqlelqlplollwqb`vbogjilpjgldqbmwjslwfnbgfafbodlrv/Efpwlmbgbwqfpsl`l`bpbabilwlgbpjmlbdvbsvfpvmlpbmwfgj`fovjpfoobnbzlylmbbnlqsjpllaqb`oj`foolgjlpklqb`bpj<[<\\!sbqhpnlvpfNlpw#---?,bnlmdaqbjmalgz#mlmf8abpfg`bqqzgqbewqfefqsbdf\\klnf-nfwfqgfobzgqfbnsqlufiljmw?,wq=gqvdp?\"..#bsqjojgfboboofmf{b`welqwk`lgfpoldj`Ujft#pffnpaobmhslqwp#+133pbufg\\ojmhdlbopdqbmwdqffhklnfpqjmdpqbwfg03s{8tklpfsbqpf+*8!#Aol`hojmv{ilmfpsj{fo$*8!=*8je+.ofewgbujgklqpfEl`vpqbjpfal{fpWqb`hfnfmw?,fn=abq!=-pq`>wltfqbow>!`baofkfmqz17s{8pfwvsjwbozpkbqsnjmlqwbpwftbmwpwkjp-qfpfwtkffodjqop,`pp,233&8`ovappwveeajaofulwfp#2333hlqfb~*8\u000E\tabmgprvfvf>#x~8;3s{8`hjmdx\u000E\t\n\nbkfbg`ol`hjqjpkojhf#qbwjlpwbwpElqn!zbkll*X3^8Balvwejmgp?,k2=gfavdwbphpVQO#>`foop~*+*821s{8sqjnfwfoopwvqmp3{533-isd!psbjmafb`kwb{fpnj`qlbmdfo..=?,djewppwfuf.ojmhalgz-~*8\t\nnlvmw#+2::EBR?,qldfqeqbmh@obpp1;s{8effgp?k2=?p`lwwwfpwp11s{8gqjmh*#\u007F\u007F#oftjppkboo 30:8#elq#olufgtbpwf33s{8ib9\u000Fnpjnlm?elmwqfsoznffwpvmwfq`kfbswjdkwAqbmg*#\">#gqfpp`ojspqllnplmhfznlajonbjm-Mbnf#sobwfevmmzwqffp`ln,!2-isdtnlgfsbqbnPWBQWofew#jggfm/#132*8\t~\telqn-ujqvp`kbjqwqbmptlqpwSbdfpjwjlmsbw`k?\"..\tl.`b`ejqnpwlvqp/333#bpjbmj((*xbglaf$*X3^jg>23alwk8nfmv#-1-nj-smd!hfujm`lb`k@kjogaqv`f1-isdVQO*(-isd\u007Fpvjwfpoj`fkbqqz213!#ptffwwq=\u000E\tmbnf>gjfdlsbdf#ptjpp..=\t\t eee8!=Old-`ln!wqfbwpkffw*#%%#27s{8poffsmwfmwejofgib9\u000Fojg>!`Mbnf!tlqpfpklwp.al{.gfowb\t%ow8afbqp97;Y?gbwb.qvqbo?,b=#psfmgabhfqpklsp>#!!8sks!=`wjlm20s{8aqjbmkfoolpjyf>l>&1E#iljmnbzaf?jnd#jnd!=/#eipjnd!#!*X3^NWlsAWzsf!mftozGbmph`yf`kwqbjohmltp?,k6=ebr!=yk.`m23*8\t.2!*8wzsf>aovfpwqvozgbujp-ip$8=\u000E\t?\"pwffo#zlv#k1=\u000E\telqn#ifpvp233&#nfmv-\u000E\t\n\u000E\ttbofpqjphpvnfmwggjmda.ojhwfb`kdje!#ufdbpgbmphffpwjpkrjspvlnjplaqfgfpgffmwqfwlglpsvfgfb/]lpfpw/Mwjfmfkbpwblwqlpsbqwfglmgfmvfulkb`fqelqnbnjpnlnfilqnvmglbrv/Ag/Abpp/_olbzvgbef`kbwlgbpwbmwlnfmlpgbwlplwqbppjwjlnv`klbklqbovdbqnbzlqfpwlpklqbpwfmfqbmwfpelwlpfpwbpsb/Apmvfubpbovgelqlpnfgjlrvjfmnfpfpslgfq`kjofpfq/Muf`fpgf`jqilp/Efpwbqufmwbdqvslkf`klfoolpwfmdlbnjdl`lpbpmjufodfmwfnjpnbbjqfpivojlwfnbpkb`jbebulqivmjlojaqfsvmwlavfmlbvwlqbaqjoavfmbwf{wlnbqylpbafqojpwbovfdl`/_nlfmfqlivfdlsfq/Vkbafqfpwlzmvm`bnvifqubolqevfqbojaqldvpwbjdvboulwlp`bplpdv/Absvfglplnlpbujplvpwfggfafmml`kfavp`bebowbfvqlppfqjfgj`kl`vqpl`obuf`bpbpof/_msobylobqdllaqbpujpwbbslzlivmwlwqbwbujpwl`qfbq`bnslkfnlp`jm`l`bqdlsjplplqgfmkb`fm/Mqfbgjp`lsfgql`fq`bsvfgbsbsfonfmlq/Vwjo`obqlilqdf`boofslmfqwbqgfmbgjfnbq`bpjdvffoobppjdol`l`kfnlwlpnbgqf`obpfqfpwlmj/]lrvfgbsbpbqabm`lkjilpujbifsbaol/Epwfujfmfqfjmlgfibqelmgl`bmbomlqwfofwqb`bvpbwlnbqnbmlpovmfpbvwlpujoobufmglsfpbqwjslpwfmdbnbq`loofubsbgqfvmjglubnlpylmbpbnalpabmgbnbqjbbavplnv`kbpvajqqjlibujujqdqbgl`kj`bboo/Ailufmgj`kbfpwbmwbofppbojqpvfolsfplpejmfpoobnbavp`l/Epwboofdbmfdqlsobybkvnlqsbdbqivmwbglaofjpobpalopbab/]lkbaobov`kb/mqfbgj`fmivdbqmlwbpuboofboo/M`bqdbglolqbabilfpw/Edvpwlnfmwfnbqjlejqnb`lpwlej`kbsobwbkldbqbqwfpofzfpbrvfonvpflabpfpsl`lpnjwbg`jfol`kj`lnjfgldbmbqpbmwlfwbsbgfafpsobzbqfgfppjfwf`lqwf`lqfbgvgbpgfpflujfilgfpfbbdvbp%rvlw8glnbjm`lnnlmpwbwvpfufmwpnbpwfqpzpwfnb`wjlmabmmfqqfnlufp`qloovsgbwfdolabonfgjvnejowfqmvnafq`kbmdfqfpvowsvaoj`p`qffm`kllpfmlqnbowqbufojppvfpplvq`fwbqdfwpsqjmdnlgvofnlajofptjw`ksklwlpalqgfqqfdjlmjwpfoepl`jbob`wjuf`lovnmqf`lqgelooltwjwof=fjwkfqofmdwkebnjozeqjfmgobzlvwbvwklq`qfbwfqfujftpvnnfqpfqufqsobzfgsobzfqf{sbmgsloj`zelqnbwglvaofsljmwppfqjfpsfqplmojujmdgfpjdmnlmwkpelq`fpvmjrvftfjdkwsflsoffmfqdzmbwvqfpfbq`kejdvqfkbujmd`vpwlnleepfwofwwfqtjmgltpvanjwqfmgfqdqlvspvsolbgkfbowknfwklgujgflpp`klloevwvqfpkbgltgfabwfubovfpLaif`wlwkfqpqjdkwpofbdvf`kqlnfpjnsofmlwj`fpkbqfgfmgjmdpfbplmqfslqwlmojmfprvbqfavwwlmjnbdfpfmbaofnlujmdobwfpwtjmwfqEqbm`fsfqjlgpwqlmdqfsfbwOlmglmgfwbjoelqnfggfnbmgpf`vqfsbppfgwlddofsob`fpgfuj`fpwbwj``jwjfppwqfbnzfooltbwwb`hpwqffweojdkwkjggfmjmel!=lsfmfgvpfevouboofz`bvpfpofbgfqpf`qfwpf`lmggbnbdfpslqwpf{`fswqbwjmdpjdmfgwkjmdpfeef`wejfogppwbwfpleej`fujpvbofgjwlqulovnfQfslqwnvpfvnnlujfpsbqfmwb``fppnlpwoznlwkfq!#jg>!nbqhfwdqlvmg`kbm`fpvqufzafelqfpznalonlnfmwpsff`knlwjlmjmpjgfnbwwfq@fmwfqlaif`wf{jpwpnjggofFvqlsfdqltwkofdb`znbmmfqfmlvdk`bqffqbmptfqlqjdjmslqwbo`ojfmwpfof`wqbmgln`olpfgwlsj`p`lnjmdebwkfqlswjlmpjnsozqbjpfgfp`bsf`klpfm`kvq`kgfejmfqfbplm`lqmfqlvwsvwnfnlqzjeqbnfsloj`fnlgfopMvnafqgvqjmdleefqppwzofphjoofgojpwfg`boofgpjoufqnbqdjmgfofwfafwwfqaqltpfojnjwpDolabopjmdoftjgdfw`fmwfqavgdfwmltqbs`qfgjw`objnpfmdjmfpbefwz`klj`fpsjqjw.pwzofpsqfbgnbhjmdmffgfgqvppjbsofbpff{wfmwP`qjswaqlhfmbooltp`kbqdfgjujgfeb`wlqnfnafq.abpfgwkflqz`lmejdbqlvmgtlqhfgkfosfg@kvq`kjnsb`wpklvogbotbzpoldl!#alwwlnojpw!=*xubq#sqfej{lqbmdfKfbgfq-svpk+`lvsofdbqgfmaqjgdfobvm`kQfujftwbhjmdujpjlmojwwofgbwjmdAvwwlmafbvwzwkfnfpelqdlwPfbq`kbm`klqbonlpwolbgfg@kbmdfqfwvqmpwqjmdqfolbgNlajofjm`lnfpvssozPlvq`flqgfqpujftfg%maps8`lvqpfBalvw#jpobmg?kwno#`llhjfmbnf>!bnbylmnlgfqmbguj`fjm?,b=9#Wkf#gjboldklvpfpAFDJM#Nf{j`lpwbqwp`fmwqfkfjdkwbggjmdJpobmgbppfwpFnsjqfP`kllofeelqwgjqf`wmfbqoznbmvboPfof`w-\t\tLmfiljmfgnfmv!=SkjojsbtbqgpkbmgofjnslqwLeej`fqfdbqgphjoopmbwjlmPslqwpgfdqfftffhoz#+f-d-afkjmggl`wlqolddfgvmjwfg?,a=?,afdjmpsobmwpbppjpwbqwjpwjppvfg033s{\u007F`bmbgbbdfm`zp`kfnfqfnbjmAqbyjopbnsofoldl!=afzlmg.p`bofb``fswpfqufgnbqjmfEllwfq`bnfqb?,k2=\t\\elqn!ofbufppwqfpp!#,=\u000E\t-dje!#lmolbgolbgfqL{elqgpjpwfqpvqujuojpwfmefnbofGfpjdmpjyf>!bssfbowf{w!=ofufopwkbmhpkjdkfqelq`fgbmjnbobmzlmfBeqj`bbdqffgqf`fmwSflsof?aq#,=tlmgfqsqj`fpwvqmfg\u007F\u007F#x~8nbjm!=jmojmfpvmgbztqbs!=ebjofg`fmpvpnjmvwfafb`lmrvlwfp263s{\u007Ffpwbwfqfnlwffnbjo!ojmhfgqjdkw8pjdmboelqnbo2-kwnopjdmvssqjm`feolbw9-smd!#elqvn-B``fppsbsfqpplvmgpf{wfmgKfjdkwpojgfqVWE.;!%bns8#Afelqf-#TjwkpwvgjlltmfqpnbmbdfsqlejwiRvfqzbmmvbosbqbnpalvdkwebnlvpdlldofolmdfqj((*#xjpqbfopbzjmdgf`jgfklnf!=kfbgfqfmpvqfaqbm`ksjf`fpaol`h8pwbwfgwls!=?qb`jmdqfpjyf..%dw8sb`jwzpf{vboavqfbv-isd!#23/333lawbjmwjwofpbnlvmw/#Jm`-`lnfgznfmv!#ozqj`pwlgbz-jmgffg`lvmwz\\oldl-EbnjozollhfgNbqhfwopf#jeSobzfqwvqhfz*8ubq#elqfpwdjujmdfqqlqpGlnbjm~fopfxjmpfqwAold?,ellwfqoldjm-ebpwfqbdfmwp?algz#23s{#3sqbdnbeqjgbzivmjlqgloobqsob`fg`lufqpsovdjm6/333#sbdf!=alpwlm-wfpw+bubwbqwfpwfg\\`lvmwelqvnpp`kfnbjmgf{/ejoofgpkbqfpqfbgfqbofqw+bssfbqPvanjwojmf!=algz!=\t)#WkfWklvdkpffjmdifqpfzMftp?,ufqjezf{sfqwjmivqztjgwk>@llhjfPWBQW#b`qlpp\\jnbdfwkqfbgmbwjufsl`hfwal{!=\tPzpwfn#Gbujg`bm`fqwbaofpsqlufgBsqjo#qfboozgqjufqjwfn!=nlqf!=albqgp`lolqp`bnsvpejqpw#\u007F\u007F#X^8nfgjb-dvjwbqejmjpktjgwk9pkltfgLwkfq#-sks!#bppvnfobzfqptjoplmpwlqfpqfojfeptfgfm@vpwlnfbpjoz#zlvq#Pwqjmd\t\tTkjowbzolq`ofbq9qfplqweqfm`kwklvdk!*#(#!?algz=avzjmdaqbmgpNfnafqmbnf!=lssjmdpf`wlq6s{8!=upsb`fslpwfqnbilq#`leeffnbqwjmnbwvqfkbssfm?,mbu=hbmpbpojmh!=Jnbdfp>ebopftkjof#kpsb`f3%bns8#\t\tJm##sltfqSlophj.`lolqilqgbmAlwwlnPwbqw#.`lvmw1-kwnomftp!=32-isdLmojmf.qjdkwnjoofqpfmjlqJPAM#33/333#dvjgfpubovf*f`wjlmqfsbjq-{no!##qjdkwp-kwno.aol`hqfdF{s9klufqtjwkjmujqdjmsklmfp?,wq=\u000Evpjmd#\t\nubq#=$*8\t\n?,wg=\t?,wq=\tabkbpbaqbpjodbofdlnbdzbqslophjpqsphj4]4C5d\bTA\nzk\u000BBl\bQ\u007F\u000BUm\u0005Gx\bSM\nmC\bTA\twQ\nd}\bW@\bTl\bTF\ti@\tcT\u000BBM\u000B|j\u0004BV\tqw\tcC\bWI\npa\tfM\n{Z\u0005{X\bTF\bVV\bVK\t\u007Fm\u0004kF\t[]\bPm\bTv\nsI\u000Bpg\t[I\bQp\u0004mx\u000B_W\n^M\npe\u000BQ}\u000BGu\nel\npe\u0004Ch\u0004BV\bTA\tSo\nzk\u000BGL\u000BxD\nd[\u0005Jz\u0005MY\bQp\u0004li\nfl\npC\u0005{B\u0005Nt\u000BwT\ti_\bTg\u0004QQ\n|p\u000BXN\bQS\u000BxD\u0004QC\bWZ\tpD\u000BVS\bTW\u0005Nt\u0004Yh\nzu\u0004Kj\u0005N}\twr\tHa\n_D\tj`\u000BQ}\u000BWp\nxZ\u0004{c\tji\tBU\nbD\u0004a|\tTn\tpV\nZd\nmC\u000BEV\u0005{X\tc}\tTo\bWl\bUd\tIQ\tcg\u000Bxs\nXW\twR\u000Bek\tc}\t]y\tJn\nrp\neg\npV\nz\\\u0005{W\npl\nz\\\nzU\tPc\t`{\bV@\nc|\bRw\ti_\bVb\nwX\tHv\u0004Su\bTF\u000B_W\u000BWs\u000BsI\u0005m\u007F\nTT\ndc\tUS\t}f\tiZ\bWz\tc}\u0004MD\tBe\tiD\u000B@@\bTl\bPv\t}t\u0004Sw\u0004M`\u000BnU\tkW\u000Bed\nqo\u000BxY\tA|\bTz\u000By`\u0004BR\u0004BM\tia\u0004XU\nyu\u0004n^\tfL\tiI\nXW\tfD\bWz\bW@\tyj\t\u007Fm\tav\tBN\u000Bb\\\tpD\bTf\nY[\tJn\bQy\t[^\u000BWc\u000Byu\u0004Dl\u0004CJ\u000BWj\u000BHR\t`V\u000BuW\tQy\np@\u000BGu\u0005pl\u0004Jm\bW[\nLP\nxC\n`m\twQ\u0005ui\u0005\u007FR\nbI\twQ\tBZ\tWV\u0004BR\npg\tcg\u0005ti\u0004CW\n_y\tRg\bQa\u000BQB\u000BWc\nYb\u0005le\ngE\u0004Su\nL[\tQ\u007F\tea\tdj\u000B]W\nb~\u0004M`\twL\bTV\bVH\nt\u007F\npl\t|b\u0005s_\bU|\bTa\u0004oQ\u0005lv\u0004Sk\u0004M`\bTv\u000BK}\nfl\tcC\u0004oQ\u0004BR\tHk\t|d\bQp\tHK\tBZ\u000BHR\bPv\u000BLx\u000BEZ\bT\u007F\bTv\tiD\u0005oD\u0005MU\u000BwB\u0004Su\u0005k`\u0004St\ntC\tPl\tKg\noi\tjY\u000BxY\u0004h}\nzk\bWZ\t\u007Fm\u000Be`\tTB\tfE\nzk\t`z\u0004Yh\nV|\tHK\tAJ\tAJ\bUL\tp\\\tql\nYc\u0004Kd\nfy\u0004Yh\t[I\u000BDg\u0004Jm\n]n\nlb\bUd\n{Z\tlu\tfs\u0004oQ\bTW\u0004Jm\u000BwB\tea\u0004Yh\u0004BC\tsb\tTn\nzU\n_y\u000BxY\tQ]\ngw\u0004mt\tO\\\ntb\bWW\bQy\tmI\tV[\ny\\\naB\u000BRb\twQ\n]Q\u0004QJ\bWg\u000BWa\bQj\ntC\bVH\nYm\u000Bxs\bVK\nel\bWI\u000BxY\u0004Cq\ntR\u000BHV\bTl\bVw\tay\bQa\bVV\t}t\tdj\nr|\tp\\\twR\n{i\nTT\t[I\ti[\tAJ\u000Bxs\u000B_W\td{\u000BQ}\tcg\tTz\tA|\tCj\u000BLm\u0005N}\u0005m\u007F\nbK\tdZ\tp\\\t`V\tsV\np@\tiD\twQ\u000BQ}\bTf\u0005ka\u0004Jm\u000B@@\bV`\tzp\n@N\u0004Sw\tiI\tcg\noi\u0004Su\bVw\u0004lo\u0004Cy\tc}\u000Bb\\\tsU\u0004BA\bWI\bTf\nxS\tVp\nd|\bTV\u000BbC\tNo\u0005Ju\nTC\t|`\n{Z\tD]\bU|\tc}\u0005lm\bTl\tBv\tPl\tc}\bQp\t\u007Fm\nLk\tkj\n@N\u0004Sb\u0004KO\tj_\tp\\\nzU\bTl\bTg\bWI\tcf\u0004XO\bWW\ndz\u0004li\tBN\nd[\bWO\u0004MD\u000BKC\tdj\tI_\bVV\ny\\\u000BLm\u0005xl\txB\tkV\u000Bb\\\u000BJW\u000BVS\tVx\u000BxD\td{\u0004MD\bTa\t|`\u000BPz\u0004R}\u000BWs\u0004BM\nsI\u0004CN\bTa\u0004Jm\npe\ti_\npV\nrh\tRd\tHv\n~A\nxR\u000BWh\u000BWk\nxS\u000BAz\u000BwX\nbI\u0004oQ\tfw\nqI\nV|\nun\u0005z\u007F\u000Bpg\td\\\u000BoA\u0005{D\ti_\u0005xB\bT\u007F\t`V\u0005qr\tTT\u0004g]\u0004CA\u000BuR\tVJ\tT`\npw\u000BRb\tI_\nCx\u0004Ro\u000BsI\u0004Cj\u0004Kh\tBv\tWV\u0004BB\u0005oD\u0005{D\nhc\u0004Km\u000B^R\tQE\n{I\np@\nc|\u0005Gt\tc}\u0004Dl\nzU\u0005qN\tsV\u0005k}\tHh\u000B|j\nqo\u0005u|\tQ]\u000Bek\u0005\u007FZ\u0004M`\u0004St\npe\tdj\bVG\u000BeE\t\u007Fm\u000BWc\u0004|I\n[W\tfL\bT\u007F\tBZ\u0004Su\u000BKa\u0004Cq\u0005Nt\u0004Y[\nqI\bTv\tfM\ti@\t}f\u0004B\\\tQy\u000BBl\bWg\u0004XD\u0005kc\u000Bx[\bVV\tQ]\t\u007Fa\tPy\u000BxD\nfI\t}f\u0005oD\tdj\tSG\u0005ls\t~D\u0004CN\n{Z\t\\v\n_D\nhc\u000Bx_\u0004C[\tAJ\nLM\tVx\u0004CI\tbj\tc^\tcF\ntC\u0004Sx\twr\u0004XA\bU\\\t|a\u000BK\\\bTV\bVj\nd|\tfs\u0004CX\ntb\bRw\tVx\tAE\tA|\bT\u007F\u0005Nt\u000BDg\tVc\bTl\u0004d@\npo\t\u007FM\tcF\npe\tiZ\tBo\bSq\nfH\u0004l`\bTx\bWf\tHE\u000BF{\tcO\tfD\nlm\u000BfZ\nlm\u000BeU\tdG\u0004BH\bTV\tSi\u0005MW\nwX\nz\\\t\\c\u0004CX\nd}\tl}\bQp\bTV\tF~\bQ\u007F\t`i\ng@\u0005nO\bUd\bTl\nL[\twQ\tji\ntC\t|J\nLU\naB\u000BxY\u0004Kj\tAJ\u0005uN\ti[\npe\u0004Sk\u000BDg\u000Bx]\bVb\bVV\nea\tkV\nqI\bTa\u0004Sk\nAO\tpD\ntb\nts\nyi\bVg\ti_\u000B_W\nLk\u0005Nt\tyj\tfM\u0004R\u007F\tiI\bTl\u000BwX\tsV\u000BMl\nyu\tAJ\bVj\u0004KO\tWV\u000BA}\u000BW\u007F\nrp\tiD\u000B|o\u0005lv\u000BsI\u0004BM\td~\tCU\bVb\u0004eV\npC\u000BwT\tj`\tc}\u000Bxs\u000Bps\u000Bvh\tWV\u000BGg\u000BAe\u000BVK\u000B]W\trg\u000BWc\u0005F`\tBr\u000Bb\\\tdZ\bQp\nqI\u0004kF\nLk\u000BAR\bWI\bTg\tbs\tdw\n{L\n_y\tiZ\bTA\tlg\bVV\bTl\tdk\n`k\ta{\ti_\u0005{A\u0005wj\twN\u000B@@\bTe\ti_\n_D\twL\nAH\u000BiK\u000Bek\n[]\tp_\tyj\bTv\tUS\t[r\n{I\nps\u0005Gt\u000BVK\npl\u0004S}\u000BWP\t|d\u0004MD\u000BHV\bT\u007F\u0004R}\u0004M`\bTV\bVH\u0005lv\u0004Ch\bW[\u0004Ke\tR{\u000B^R\tab\tBZ\tVA\tB`\nd|\nhs\u0004Ke\tBe\u0004Oi\tR{\td\\\u0005nB\bWZ\tdZ\tVJ\u0005Os\t\u007Fm\u0004uQ\u000BhZ\u0004Q@\u0004QQ\nfI\bW[\u0004B\\\u0004li\nzU\nMd\u0004M`\nxS\bVV\n\\}\u000BxD\t\u007Fm\bTp\u0004IS\nc|\tkV\u0005i~\tV{\u000BhZ\t|b\bWt\n@R\u000BoA\u000BnU\bWI\tea\tB`\tiD\tc}\tTz\u0004BR\u000BQB\u0005Nj\tCP\t[I\bTv\t`W\u0005uN\u000Bpg\u000Bpg\u000BWc\tiT\tbs\twL\tU_\tc\\\t|h\u000BKa\tNr\tfL\nq|\nzu\nz\\\tNr\bUg\t|b\u0004m`\bTv\nyd\nrp\bWf\tUX\u0004BV\nzk\nd}\twQ\t}f\u0004Ce\u000Bed\bTW\bSB\nxU\tcn\bTb\ne\u007F\ta\\\tSG\bU|\npV\nN\\\u0004Kn\u000BnU\tAt\tpD\u000B^R\u000BIr\u0004b[\tR{\tdE\u000BxD\u000BWK\u000BWA\bQL\bW@\u0004Su\bUd\nDM\tPc\u0004CA\u0004Dl\u0004oQ\tHs\u0005wi\u0004ub\n\u007Fa\bQp\u0005Ob\nLP\bTl\u0004Y[\u000BK}\tAJ\bQ\u007F\u0004n^\u000BsA\bSM\nqM\bWZ\n^W\u000Bz{\u0004S|\tfD\bVK\bTv\bPv\u0004BB\tCP\u0004dF\tid\u000Bxs\u0004mx\u000Bws\tcC\ntC\tyc\u0005M`\u000BW\u007F\nrh\bQp\u000BxD\u0004\\o\nsI\u0004_k\nzu\u0004kF\tfD\u0004Xs\u0004XO\tjp\bTv\u0004BS\u0005{B\tBr\nzQ\nbI\tc{\u0004BD\u0004BV\u0005nO\bTF\tca\u0005Jd\tfL\tPV\tI_\nlK\u0004`o\twX\npa\tgu\bP}\u0005{^\bWf\n{I\tBN\npa\u0004Kl\u000Bpg\tcn\tfL\u000Bvh\u0004Cq\bTl\u000BnU\bSq\u0004Cm\twR\bUJ\npe\nyd\nYg\u0004Cy\u000BKW\tfD\nea\u0004oQ\tj_\tBv\u0004nM\u000BID\bTa\nzA\u0005pl\n]n\bTa\tR{\tfr\n_y\bUg\u0005{X\u0005kk\u000BxD\u0004|I\u0005xl\nfy\u0004Ce\u000BwB\nLk\u000Bd]\noi\n}h\tQ]\npe\bVw\u0004Hk\u0004OQ\nzk\tAJ\npV\bPv\ny\\\tA{\u0004Oi\bSB\u0004XA\u000BeE\tjp\nq}\tiD\u0005qN\u000B^R\t\u007Fm\tiZ\tBr\bVg\noi\n\\X\tU_\nc|\u000BHV\bTf\tTn\u0004\\N\u0004\\N\nuB\u0005lv\nyu\tTd\bTf\bPL\u000B]W\tdG\nA`\nw^\ngI\npe\tdw\nz\\\u0005ia\bWZ\tcF\u0004Jm\n{Z\bWO\u0004_k\u0004Df\u0004RR\td\\\bVV\u000Bxs\u0004BN\u0005ti\u0004lm\tTd\t]y\u000BHV\tSo\u000B|j\u0004XX\tA|\u000BZ^\u000BGu\bTW\u0005M`\u0004kF\u000BhZ\u000BVK\tdG\u000BBl\tay\nxU\u0005qE\u0005nO\bVw\nqI\u0004CX\ne\u007F\tPl\bWO\u000BLm\tdL\u0005uH\u0004Cm\tdT\u0004fn\u000BwB\u0005ka\u000BnU\n@M\nyT\tHv\t\\}\u0004Kh\td~\u0004Yh\u0005k}\neR\td\\\bWI\t|b\tHK\tiD\bTW\u0005MY\npl\bQ_\twr\u000BAx\tHE\bTg\bSq\u0005vp\u000Bb\\\bWO\nOl\nsI\nfy\u000BID\t\\c\n{Z\n^~\npe\nAO\tTT\u000Bxv\u0004k_\bWO\u000B|j\u000BwB\tQy\ti@\tPl\tHa\tdZ\u0005k}\u0004ra\tUT\u000BJc\u000Bed\np@\tQN\nd|\tkj\tHk\u0004M`\noi\twr\td\\\nlq\no_\nlb\nL[\tac\u0004BB\u0004BH\u0004Cm\npl\tIQ\bVK\u000Bxs\n`e\u000BiK\npa\u0004Oi\tUS\bTp\tfD\nPG\u0005kk\u0004XA\nz\\\neg\u000BWh\twR\u0005qN\nqS\tcn\u0004lo\nxS\n^W\tBU\nt\u007F\tHE\tp\\\tfF\tfw\bVV\bW@\tak\u000BVK\u0005ls\tVJ\bVV\u000BeE\u0004\\o\nyX\nYm\u0004M`\u0005lL\nd|\nzk\tA{\u0005sE\twQ\u0004XT\nt\u007F\tPl\t]y\u000BwT\u0005{p\u0004MD\u000Bb\\\tQ]\u0004Kj\tJn\nAH\u000BRb\tBU\tHK\t\\c\nfI\u0005m\u007F\nqM\n@R\tSo\noi\u0004BT\tHv\n_y\u0004Kh\tBZ\t]i\bUJ\tV{\u0004Sr\nbI\u000BGg\ta_\bTR\nfI\nfl\t[K\tII\u0004S|\u000BuW\tiI\bWI\nqI\u000B|j\u0004BV\bVg\bWZ\u0004kF\u000Bx]\bTA\tab\tfr\ti@\tJd\tJd\u000Bps\nAO\bTa\u0005xu\tiD\nzk\t|d\t|`\bW[\tlP\tdG\bVV\u000Bw}\u000BqO\ti[\bQ\u007F\bTz\u000BVF\twN\u0005ts\tdw\bTv\neS\ngi\tNr\u0005yS\npe\bVV\bSq\n`m\tyj\tBZ\u000BWX\bSB\tc\\\nUR\t[J\tc_\u0004nM\bWQ\u000BAx\nMd\tBr\u0005ui\u000BxY\bSM\u000BWc\u000B|j\u000Bxs\t}Q\tBO\bPL\bWW\tfM\nAO\tPc\u000BeU\u0004e^\bTg\nqI\tac\bPv\tcF\u0004oQ\tQ\u007F\u000BhZ\u0005ka\nz\\\tiK\tBU\n`k\tCP\u0004S|\u0004M`\n{I\tS{\u0004_O\tBZ\u0004Zi\u0004Sk\tps\tp\\\nYu\n]s\nxC\bWt\nbD\tkV\u000BGu\u0005yS\nqA\t[r\neK\u0004M`\tdZ\u0005lL\bUg\bTl\nbD\tUS\u000Bb\\\tpV\ncc\u0004S\\\tct\t`z\bPL\u000BWs\nA`\neg\bSq\u0005uE\u0004CR\u000BDg\t`W\u000Bz{\u000BWc\u0004Sk\u0004Sk\tbW\bUg\tea\nxZ\tiI\tUX\tVJ\nqn\tS{\u000BRb\bTQ\npl\u0005Gt\u000BuW\u0005uj\npF\nqI\tfL\t[I\tia\u0004XO\nyu\u000BDg\u000Bed\tq{\u0004VG\bQ\u007F\u0005ka\tVj\tkV\txB\nd|\np@\tQN\tPc\tps\u0004]j\tkV\toU\bTp\nzU\u0005nB\u000BB]\ta{\bV@\n]n\u0004m`\tcz\tR{\u0004m`\bQa\u000BwT\bSM\u0005MY\u0005qN\tdj\u0005~s\u000BQ}\u0005MY\u000BMB\tBv\twR\bRg\u000BQ}\tql\u000BKC\nrm\u0005xu\u0004CC\u000BwB\u000Bvh\tBq\u0004Xq\npV\ti_\u0005Ob\u0005uE\nbd\nqo\u000B{i\nC~\tBL\u000BeE\u0005uH\bVj\u0004Ey\u0004Gz\u000BzR\u000B{i\tcf\n{Z\n]n\u0004XA\u000BGu\u000BnU\thS\u000BGI\nCc\tHE\bTA\tHB\u0004BH\u0004Cj\nCc\bTF\tHE\nXI\tA{\bQ\u007F\tc\\\u000BmO\u000BWX\nfH\np@\u0005MY\bTF\nlK\tBt\nzU\tTT\u0004Km\u000BwT\npV\ndt\u000ByI\tVx\tQ\u007F\tRg\tTd\nzU\bRS\nLM\twA\u0004nM\tTn\ndS\t]g\nLc\u000BwB\t}t\t[I\tCP\u0004kX\u000BFm\u000BhZ\u0005m\u007F\ti[\np@\u000BQ}\u000BW\u007F\t|d\nMO\nMd\tf_\tfD\tcJ\tHz\u000BRb\tio\tPy\u0004Y[\nxU\tct\u000B@@\tww\bPv\u0004BM\u0004FF\ntb\u0005v|\u000BKm\tBq\tBq\u0004Kh\u0004`o\nZd\u0004XU\ti]\t|`\tSt\u0004B\\\bQ\u007F\u000B_W\tTJ\nqI\t|a\tA{\u000BuP\u0004MD\tPl\nxR\tfL\u000Bws\tc{\td\\\bV`\neg\tHK\u0005kc\nd|\bVV\ny\\\u0005kc\ti]\bVG\t`V\tss\tI_\tAE\tbs\tdu\nel\tpD\u000BW\u007F\nqs\u0005lv\bSM\u0004Zi\u000BVK\u0005ia\u000BQB\tQ\u007F\n{Z\bPt\u000BKl\nlK\nhs\ndS\bVK\u0005mf\nd^\tkV\tcO\nc|\bVH\t\\]\bTv\bSq\tmI\u000BDg\tVJ\tcn\ny\\\bVg\bTv\nyX\bTF\t]]\bTp\noi\nhs\u000BeU\nBf\tdj\u0005Mr\n|p\t\\g\t]r\bVb\u0005{D\nd[\u0004XN\tfM\tO\\\u0005s_\tcf\tiZ\u0004XN\u000BWc\tqv\n`m\tU^\u0005oD\nd|\u000BGg\tdE\u000Bwf\u0004lo\u0004u}\nd|\u0005oQ\t`i\u0004Oi\u000BxD\ndZ\nCx\u0004Yw\nzk\ntb\ngw\tyj\tB`\nyX\u000Bps\ntC\u000BpP\u000Bqw\bPu\bPX\tDm\npw\u0005Nj\tss\taG\u000Bxs\bPt\noL\u0004Gz\tOk\ti@\ti]\u0004eC\tIQ\tii\tdj\u000B@J\t|d\u0005uh\bWZ\u000BeU\u000BnU\bTa\tcC\u0004g]\nzk\u0004Yh\bVK\nLU\np@\ntb\ntR\tCj\u000BNP\ti@\bP{\n\\}\n{c\nwX\tfL\bVG\tc{\t|`\tAJ\t|C\tfD\u0005ln\t|d\tbs\nqI\u0005{B\u000BAx\np@\nzk\u000BRb\u0005Os\u000BWS\u0004e^\u000BD_\tBv\u000BWd\bVb\u000Bxs\u000BeE\bRw\n]n\n|p\u000Bg|\tfw\u0005kc\bTI\u0005ka\n\\T\u0004Sp\tju\u000Bps\npe\u0005u|\u000BGr\bVe\tCU\u0004]M\u0004XU\u000BxD\bTa\tIQ\u000BWq\tCU\tam\tdj\bSo\u0004Sw\u000BnU\u0004Ch\tQ]\u0005s_\bPt\tfS\bTa\t\\}\n@O\u0004Yc\tUZ\bTx\npe\u000BnU\nzU\t|}\tiD\nz\\\bSM\u000BxD\u0004BR\nzQ\tQN\u0004]M\u0004Yh\nLP\u000BFm\u000BLX\u0005vc\u000Bql\u0005ka\tHK\bVb\ntC\nCy\bTv\nuV\u0004oQ\t`z\t[I\tB`\u000BRb\tyj\tsb\u000BWs\bTl\tkV\u000Bed\ne\u007F\u0005lL\u000BxN\t\u007Fm\nJn\tjY\u000BxD\bVb\bSq\u000Byu\twL\u000BXL\bTA\tpg\tAt\tnD\u0004XX\twR\npl\nhw\u0005yS\nps\tcO\bW[\u000B|j\u0004XN\tsV\tp\\\tBe\nb~\nAJ\n]e\u0005k`\u0005qN\tdw\tWV\tHE\u000BEV\u0005Jz\tid\tB`\tzh\u0005E]\tfD\bTg\u0005qN\bTa\tja\u0004Cv\bSM\nhc\bUe\u0005t_\tie\u0004g]\twQ\nPn\bVB\tjw\bVg\u000BbE\tBZ\u000BRH\bP{\tjp\n\\}\ta_\tcC\t|a\u000BD]\tBZ\ti[\tfD\u000BxW\no_\td\\\n_D\ntb\t\\c\tAJ\nlK\u0004oQ\u0004lo\u000BLx\u000BM@\bWZ\u0004Kn\u000Bpg\nTi\nIv\n|r\u000B@}\u0005Jz\u0005Lm\u0005Wh\u0005k}\u0005ln\u000BxD\n]s\u0004gc\u000Bps\tBr\bTW\u000BBM\u0005tZ\nBY\u0004DW\tjf\u000BSW\u0004C}\nqo\tdE\tmv\tIQ\bPP\bUb\u0005lv\u0004BC\nzQ\t[I\u000Bgl\nig\bUs\u0004BT\u000BbC\bSq\tsU\tiW\nJn\tSY\tHK\trg\npV\u000BID\u000B|j\u0004KO\t`S\t|a`vbmglfmujbqnbgqjgavp`bqjmj`jlwjfnslslqrvf`vfmwbfpwbglsvfgfmivfdlp`lmwqbfpw/Mmmlnaqfwjfmfmsfqejonbmfqbbnjdlp`jvgbg`fmwqlbvmrvfsvfgfpgfmwqlsqjnfqsqf`jlpfd/Vmavfmlpuloufqsvmwlppfnbmbkba/Abbdlpwlmvfulpvmjglp`bqolpfrvjslmj/]lpnv`klpbodvmb`lqqfljnbdfmsbqwjqbqqjabnbq/Abklnaqffnsoflufqgbg`bnajlnv`kbpevfqlmsbpbglo/Amfbsbqf`fmvfubp`vqplpfpwbabrvjfqlojaqlp`vbmwlb``fplnjdvfoubqjlp`vbwqlwjfmfpdqvslppfq/Mmfvqlsbnfgjlpeqfmwfb`fq`bgfn/Mplefqwb`l`kfpnlgfoljwbojbofwqbpbod/Vm`lnsqb`vbofpf{jpwf`vfqslpjfmglsqfmpboofdbqujbifpgjmfqlnvq`jbslgq/Msvfpwlgjbqjlsvfaolrvjfqfnbmvfosqlsjl`qjpjp`jfqwlpfdvqlnvfqwfevfmwf`fqqbqdqbmgffef`wlsbqwfpnfgjgbsqlsjbleqf`fwjfqqbf.nbjoubqjbpelqnbpevwvqllaifwlpfdvjqqjfpdlmlqnbpnjpnlp/Vmj`l`bnjmlpjwjlpqby/_mgfajglsqvfabwlofglwfm/Abifp/Vpfpsfql`l`jmblqjdfmwjfmgb`jfmwl`/Mgjykbaobqpfq/Abobwjmbevfqybfpwjoldvfqqbfmwqbq/E{jwlo/_sfybdfmgbu/Agflfujwbqsbdjmbnfwqlpibujfqsbgqfpe/M`jo`bafyb/Mqfbppbojgbfmu/Alibs/_mbavplpajfmfpwf{wlpoofubqsvfgbmevfqwf`ln/Vm`obpfpkvnbmlwfmjglajoablvmjgbgfpw/Mpfgjwbq`qfbgl2%bns8Kjpwlqz#>#mft#@fmwqbovsgbwfgPsf`jboMfwtlqhqfrvjqf`lnnfmwtbqmjmd@loofdfwlloabqqfnbjmpaf`bvpffof`wfgGfvwp`kejmbm`ftlqhfqprvj`hozafwtffmf{b`wozpfwwjmdgjpfbpfPl`jfwztfbslmpf{kjajw%ow8\"..@lmwqlo`obppfp`lufqfglvwojmfbwwb`hpgfuj`fp+tjmgltsvqslpfwjwof>!Nlajof#hjoojmdpkltjmdJwbojbmgqlssfgkfbujozfeef`wp.2$^*8\t`lmejqn@vqqfmwbgubm`fpkbqjmdlsfmjmdgqbtjmdajoojlmlqgfqfgDfqnbmzqfobwfg?,elqn=jm`ovgftkfwkfqgfejmfgP`jfm`f`bwboldBqwj`ofavwwlmpobqdfpwvmjelqnilvqmfzpjgfabq@kj`bdlklojgbzDfmfqbosbppbdf/%rvlw8bmjnbwfeffojmdbqqjufgsbppjmdmbwvqboqlvdkoz-\t\tWkf#avw#mlwgfmpjwzAqjwbjm@kjmfpfob`h#lewqjavwfJqfobmg!#gbwb.eb`wlqpqf`fjufwkbw#jpOjaqbqzkvpabmgjm#eb`wbeebjqp@kbqofpqbgj`boaqlvdkwejmgjmdobmgjmd9obmd>!qfwvqm#ofbgfqpsobmmfgsqfnjvnsb`hbdfBnfqj`bFgjwjlm^%rvlw8Nfppbdfmffg#wlubovf>!`lnsof{ollhjmdpwbwjlmafojfufpnboofq.nlajofqf`lqgptbmw#wlhjmg#leEjqfel{zlv#bqfpjnjobqpwvgjfgnb{jnvnkfbgjmdqbsjgoz`ojnbwfhjmdglnfnfqdfgbnlvmwpelvmgfgsjlmffqelqnvobgzmbpwzklt#wl#Pvsslqwqfufmvff`lmlnzQfpvowpaqlwkfqplogjfqobqdfoz`boojmd-%rvlw8B``lvmwFgtbqg#pfdnfmwQlafqw#feelqwpSb`jej`ofbqmfgvs#tjwkkfjdkw9tf#kbufBmdfofpmbwjlmp\\pfbq`kbssojfgb`rvjqfnbppjufdqbmwfg9#ebopfwqfbwfgajddfpwafmfejwgqjujmdPwvgjfpnjmjnvnsfqkbspnlqmjmdpfoojmdjp#vpfgqfufqpfubqjbmw#qlof>!njppjmdb`kjfufsqlnlwfpwvgfmwplnflmff{wqfnfqfpwlqfalwwln9fuloufgboo#wkfpjwfnbsfmdojpktbz#wl##Bvdvpwpznalop@lnsbmznbwwfqpnvpj`bobdbjmpwpfqujmd~*+*8\u000E\tsbznfmwwqlvaof`lm`fsw`lnsbqfsbqfmwpsobzfqpqfdjlmpnlmjwlq#$$Wkf#tjmmjmdf{solqfbgbswfgDboofqzsqlgv`fbajojwzfmkbm`f`bqffqp*-#Wkf#`loof`wPfbq`k#bm`jfmwf{jpwfgellwfq#kbmgofqsqjmwfg`lmplofFbpwfqmf{slqwptjmgltp@kbmmfojoofdbomfvwqbopvddfpw\\kfbgfqpjdmjmd-kwno!=pfwwofgtfpwfqm`bvpjmd.tfahjw`objnfgIvpwj`f`kbswfquj`wjnpWklnbp#nlyjoobsqlnjpfsbqwjfpfgjwjlmlvwpjgf9ebopf/kvmgqfgLoznsj`\\avwwlmbvwklqpqfb`kfg`kqlmj`gfnbmgppf`lmgpsqlwf`wbglswfgsqfsbqfmfjwkfqdqfbwozdqfbwfqlufqboojnsqluf`lnnbmgpsf`jbopfbq`k-tlqpkjsevmgjmdwklvdkwkjdkfpwjmpwfbgvwjojwzrvbqwfq@vowvqfwfpwjmd`ofbqozf{slpfgAqltpfqojafqbo~#`bw`kSqlif`wf{bnsofkjgf+*8EolqjgbbmptfqpbooltfgFnsfqlqgfefmpfpfqjlvpeqffglnPfufqbo.avwwlmEvqwkfqlvw#le#\">#mvoowqbjmfgGfmnbqhuljg+3*,boo-ipsqfufmwQfrvfpwPwfskfm\t\tTkfm#lapfquf?,k1=\u000E\tNlgfqm#sqlujgf!#bow>!alqgfqp-\t\tElq#\t\tNbmz#bqwjpwpsltfqfgsfqelqnej`wjlmwzsf#lenfgj`bowj`hfwplsslpfg@lvm`jotjwmfppivpwj`fDflqdf#Afodjvn---?,b=wtjwwfqmlwbaoztbjwjmdtbqebqf#Lwkfq#qbmhjmdskqbpfpnfmwjlmpvqujufp`klobq?,s=\u000E\t#@lvmwqzjdmlqfgolpp#leivpw#bpDflqdjbpwqbmdf?kfbg=?pwlssfg2$^*8\u000E\tjpobmgpmlwbaofalqgfq9ojpw#le`bqqjfg233/333?,k0=\t#pfufqboaf`lnfppfof`w#tfggjmd33-kwnonlmbq`klee#wkfwfb`kfqkjdkoz#ajloldzojef#lelq#fufmqjpf#le%qbrvl8sovplmfkvmwjmd+wklvdkGlvdobpiljmjmd`jq`ofpElq#wkfBm`jfmwUjfwmbnufkj`ofpv`k#bp`qzpwboubovf#>Tjmgltpfmilzfgb#pnboobppvnfg?b#jg>!elqfjdm#Boo#qjklt#wkfGjpsobzqfwjqfgkltfufqkjggfm8abwwofppffhjmd`bajmfwtbp#mlwollh#bw`lmgv`wdfw#wkfIbmvbqzkbssfmpwvqmjmdb9klufqLmojmf#Eqfm`k#ob`hjmdwzsj`bof{wqb`wfmfnjfpfufm#jedfmfqbwgf`jgfgbqf#mlw,pfbq`kafojfep.jnbdf9ol`bwfgpwbwj`-oldjm!=`lmufqwujlofmwfmwfqfgejqpw!=`jq`vjwEjmobmg`kfnjpwpkf#tbp23s{8!=bp#pv`kgjujgfg?,psbm=tjoo#afojmf#leb#dqfbwnzpwfqz,jmgf{-eboojmdgvf#wl#qbjotbz`loofdfnlmpwfqgfp`fmwjw#tjwkmv`ofbqIftjpk#sqlwfpwAqjwjpkeoltfqpsqfgj`wqfelqnpavwwlm#tkl#tbpof`wvqfjmpwbmwpvj`jgfdfmfqj`sfqjlgpnbqhfwpPl`jbo#ejpkjmd`lnajmfdqbskj`tjmmfqp?aq#,=?az#wkf#MbwvqboSqjub`z`llhjfplvw`lnfqfploufPtfgjpkaqjfeozSfqpjbmpl#nv`k@fmwvqzgfsj`wp`lovnmpklvpjmdp`qjswpmf{w#wlafbqjmdnbssjmdqfujpfgiRvfqz+.tjgwk9wjwof!=wllowjsPf`wjlmgfpjdmpWvqhjpkzlvmdfq-nbw`k+~*+*8\t\tavqmjmdlsfqbwfgfdqffpplvq`f>Qj`kbqg`olpfozsobpwj`fmwqjfp?,wq=\u000E\t`lolq9 vo#jg>!slppfppqloojmdskzpj`pebjojmdf{f`vwf`lmwfpwojmh#wlGfebvow?aq#,=\t9#wqvf/`kbqwfqwlvqjpn`obppj`sql`ffgf{sobjm?,k2=\u000E\tlmojmf-<{no#ufkfosjmdgjbnlmgvpf#wkfbjqojmffmg#..=*-bwwq+qfbgfqpklpwjmd eeeeeeqfbojyfUjm`fmwpjdmbop#pq`>!,Sqlgv`wgfpsjwfgjufqpfwfoojmdSvaoj`#kfog#jmIlpfsk#wkfbwqfbeef`wp?pwzof=b#obqdfglfpm$wobwfq/#Fofnfmwebuj`lm`qfbwlqKvmdbqzBjqslqwpff#wkfpl#wkbwNj`kbfoPzpwfnpSqldqbnp/#bmg##tjgwk>f%rvlw8wqbgjmdofew!=\tsfqplmpDlogfm#Beebjqpdqbnnbqelqnjmdgfpwqlzjgfb#le`bpf#lelogfpw#wkjp#jp-pq`#>#`bqwllmqfdjpwq@lnnlmpNvpojnpTkbw#jpjm#nbmznbqhjmdqfufbopJmgffg/frvbooz,pklt\\blvwgllqfp`bsf+Bvpwqjbdfmfwj`pzpwfn/Jm#wkf#pjwwjmdKf#boplJpobmgpB`bgfnz\t\n\n?\"..Gbmjfo#ajmgjmdaol`h!=jnslpfgvwjojyfBaqbkbn+f{`fswxtjgwk9svwwjmd*-kwno+\u007F\u007F#X^8\tGBWBX#)hjw`kfmnlvmwfgb`wvbo#gjbof`wnbjmoz#\\aobmh$jmpwboof{sfqwpje+wzsfJw#bopl%`lsz8#!=Wfqnpalqm#jmLswjlmpfbpwfqmwbohjmd`lm`fqmdbjmfg#lmdljmdivpwjez`qjwj`peb`wlqzjwp#ltmbppbvowjmujwfgobpwjmdkjp#ltmkqfe>!,!#qfo>!gfufols`lm`fqwgjbdqbngloobqp`ovpwfqsksbo`lklo*8~*+*8vpjmd#b=?psbm=ufppfopqfujuboBggqfppbnbwfvqbmgqljgboofdfgjoomfpptbohjmd`fmwfqprvbojeznbw`kfpvmjejfgf{wjm`wGfefmpfgjfg#jm\t\n?\"..#`vpwlnpojmhjmdOjwwof#Allh#lefufmjmdnjm-iptfbqjmdBoo#Qjd8\t~*+*8qbjpjmd#Bopl/#`qv`jbobalvw!=gf`obqf..=\t?p`ejqfel{bp#nv`kbssojfpjmgf{/#p/#avw#wzsf#>#\t\u000E\t?\"..wltbqgpQf`lqgpSqjubwfElqfjdmSqfnjfq`klj`fpUjqwvboqfwvqmp@lnnfmwSltfqfgjmojmf8slufqwz`kbnafqOjujmd#ulovnfpBmwklmzoldjm!#QfobwfgF`lmlnzqfb`kfp`vwwjmddqbujwzojef#jm@kbswfq.pkbgltMlwbaof?,wg=\u000E\t#qfwvqmpwbgjvntjgdfwpubqzjmdwqbufopkfog#aztkl#bqftlqh#jmeb`vowzbmdvobqtkl#kbgbjqslqwwltm#le\t\tPlnf#$`oj`h$`kbqdfphfztlqgjw#tjoo`jwz#le+wkjp*8Bmgqft#vmjrvf#`kf`hfglq#nlqf033s{8#qfwvqm8qpjlm>!sovdjmptjwkjm#kfqpfoePwbwjlmEfgfqboufmwvqfsvaojpkpfmw#wlwfmpjlmb`wqfpp`lnf#wlejmdfqpGvhf#lesflsof/f{soljwtkbw#jpkbqnlmzb#nbilq!9!kwwsjm#kjp#nfmv!=\tnlmwkozleej`fq`lvm`jodbjmjmdfufm#jmPvnnbqzgbwf#leolzbowzejwmfppbmg#tbpfnsfqlqpvsqfnfPf`lmg#kfbqjmdQvppjbmolmdfpwBoafqwbobwfqbopfw#le#pnboo!=-bssfmggl#tjwkefgfqboabmh#leafmfbwkGfpsjwf@bsjwbodqlvmgp*/#bmg#sfq`fmwjw#eqln`olpjmd`lmwbjmJmpwfbgejewffmbp#tfoo-zbkll-qfpslmgejdkwfqlap`vqfqfeof`wlqdbmj`>#Nbwk-fgjwjmdlmojmf#sbggjmdb#tkloflmfqqlqzfbq#lefmg#le#abqqjfqtkfm#jwkfbgfq#klnf#leqfpvnfgqfmbnfgpwqlmd=kfbwjmdqfwbjmp`olvgeqtbz#le#Nbq`k#2hmltjmdjm#sbqwAfwtffmofpplmp`olpfpwujqwvboojmhp!=`qlppfgFMG#..=ebnlvp#btbqgfgOj`fmpfKfbowk#ebjqoz#tfbowkznjmjnboBeqj`bm`lnsfwfobafo!=pjmdjmdebqnfqpAqbpjo*gjp`vppqfsob`fDqfdlqzelmw#`lsvqpvfgbssfbqpnbhf#vsqlvmgfgalwk#leaol`hfgpbt#wkfleej`fp`lolvqpje+gl`vtkfm#kffmelq`fsvpk+evBvdvpw#VWE.;!=Ebmwbpzjm#nlpwjmivqfgVpvboozebqnjmd`olpvqflaif`w#gfefm`fvpf#le#Nfgj`bo?algz=\tfujgfmwaf#vpfghfz@lgfpj{wffmJpobnj` 333333fmwjqf#tjgfoz#b`wjuf#+wzsflelmf#`bm`lolq#>psfbhfqf{wfmgpSkzpj`pwfqqbjm?walgz=evmfqboujftjmdnjggof#`qj`hfwsqlskfwpkjewfggl`wlqpQvppfoo#wbqdfw`lnsb`wbodfaqbpl`jbo.avoh#lenbm#bmg?,wg=\t#kf#ofew*-ubo+*ebopf*8oldj`boabmhjmdklnf#wlmbnjmd#Bqjylmb`qfgjwp*8\t~*8\telvmgfqjm#wvqm@loojmpafelqf#Avw#wkf`kbqdfgWjwof!=@bswbjmpsfoofgdlggfppWbd#..=Bggjmd9avw#tbpQf`fmw#sbwjfmwab`h#jm>ebopf%Ojm`lomtf#hmlt@lvmwfqIvgbjpnp`qjsw#bowfqfg$^*8\t##kbp#wkfvm`ofbqFufmw$/alwk#jmmlw#boo\t\t?\"..#sob`jmdkbqg#wl#`fmwfqplqw#le`ojfmwppwqffwpAfqmbqgbppfqwpwfmg#wlebmwbpzgltm#jmkbqalvqEqffglniftfoqz,balvw--pfbq`kofdfmgpjp#nbgfnlgfqm#lmoz#lmlmoz#wljnbdf!#ojmfbq#sbjmwfqbmg#mlwqbqfoz#b`qlmzngfojufqpklqwfq33%bns8bp#nbmztjgwk>!,)#?\"X@wjwof#>le#wkf#oltfpw#sj`hfg#fp`bsfgvpfp#lesflsofp#Svaoj`Nbwwkftwb`wj`pgbnbdfgtbz#elqobtp#lefbpz#wl#tjmgltpwqlmd##pjnsof~`bw`k+pfufmwkjmelal{tfmw#wlsbjmwfg`jwjyfmJ#glm$wqfwqfbw-#Plnf#tt-!*8\talnajmdnbjowl9nbgf#jm-#Nbmz#`bqqjfp\u007F\u007Fx~8tjtlqh#lepzmlmzngfefbwpebulqfglswj`bosbdfWqbvmofpp#pfmgjmdofew!=?`lnP`lqBoo#wkfiRvfqz-wlvqjpw@obppj`ebopf!#Tjokfonpvavqapdfmvjmfajpklsp-psojw+dolabo#elooltpalgz#lemlnjmbo@lmwb`wpf`vobqofew#wl`kjfeoz.kjggfm.abmmfq?,oj=\t\t-#Tkfm#jm#alwkgjpnjppF{solqfbotbzp#ujb#wkfpsb/]lotfoebqfqvojmd#bqqbmdf`bswbjmkjp#plmqvof#lekf#wllhjwpfoe/>3%bns8+`boofgpbnsofpwl#nbhf`ln,sbdNbqwjm#Hfmmfgzb``fswpevoo#lekbmgofgAfpjgfp,,..=?,baof#wlwbqdfwpfppfm`fkjn#wl#jwp#az#`lnnlm-njmfqbowl#wbhftbzp#wlp-lqd,obgujpfgsfmbowzpjnsof9je#wkfzOfwwfqpb#pklqwKfqafqwpwqjhfp#dqlvsp-ofmdwkeojdkwplufqobspoltoz#ofppfq#pl`jbo#?,s=\t\n\njw#jmwlqbmhfg#qbwf#levo=\u000E\t##bwwfnswsbjq#lenbhf#jwHlmwbhwBmwlmjlkbujmd#qbwjmdp#b`wjufpwqfbnpwqbssfg!*-`pp+klpwjofofbg#wlojwwof#dqlvsp/Sj`wvqf..=\u000E\t\u000E\t#qltp>!#laif`wjmufqpf?ellwfq@vpwlnU=?_,p`qploujmd@kbnafqpobufqztlvmgfgtkfqfbp\">#$vmgelq#boosbqwoz#.qjdkw9Bqbajbmab`hfg#`fmwvqzvmjw#lenlajof.Fvqlsf/jp#klnfqjph#legfpjqfg@ojmwlm`lpw#lebdf#le#af`lnf#mlmf#les%rvlw8Njggof#fbg$*X3@qjwj`ppwvgjlp=%`lsz8dqlvs!=bppfnaonbhjmd#sqfppfgtjgdfw-sp9!#<#qfavjowaz#plnfElqnfq#fgjwlqpgfobzfg@bmlmj`kbg#wkfsvpkjmd`obpp>!avw#bqfsbqwjboAbazolmalwwln#`bqqjfq@lnnbmgjwp#vpfBp#tjwk`lvqpfpb#wkjqggfmlwfpbopl#jmKlvpwlm13s{8!=b``vpfgglvaof#dlbo#leEbnlvp#*-ajmg+sqjfpwp#Lmojmfjm#Ivozpw#(#!d`lmpvowgf`jnbokfosevoqfujufgjp#ufqzq$($jswolpjmd#efnbofpjp#boplpwqjmdpgbzp#lebqqjuboevwvqf#?laif`welq`jmdPwqjmd+!#,=\t\n\nkfqf#jpfm`lgfg-##Wkf#aboollmglmf#az,`lnnlmad`lolqobt#le#Jmgjbmbbuljgfgavw#wkf1s{#0s{irvfqz-bewfq#bsloj`z-nfm#bmgellwfq.>#wqvf8elq#vpfp`qffm-Jmgjbm#jnbdf#>ebnjoz/kwws9,,#%maps8gqjufqpfwfqmbopbnf#bpmlwj`fgujftfqp~*+*8\t#jp#nlqfpfbplmpelqnfq#wkf#mftjp#ivpw`lmpfmw#Pfbq`ktbp#wkftkz#wkfpkjssfgaq=?aq=tjgwk9#kfjdkw>nbgf#le`vjpjmfjp#wkbwb#ufqz#Bgnjqbo#ej{fg8mlqnbo#NjppjlmSqfpp/#lmwbqjl`kbqpfwwqz#wl#jmubgfg>!wqvf!psb`jmdjp#nlpwb#nlqf#wlwboozeboo#le~*8\u000E\t##jnnfmpfwjnf#jmpfw#lvwpbwjpezwl#ejmggltm#wlolw#le#Sobzfqpjm#Ivmfrvbmwvnmlw#wkfwjnf#wlgjpwbmwEjmmjpkpq`#>#+pjmdof#kfos#leDfqnbm#obt#bmgobafofgelqfpwp`llhjmdpsb`f!=kfbgfq.tfoo#bpPwbmofzaqjgdfp,dolabo@qlbwjb#Balvw#X3^8\t##jw/#bmgdqlvsfgafjmd#b*xwkqltkf#nbgfojdkwfqfwkj`boEEEEEE!alwwln!ojhf#b#fnsolzpojuf#jmbp#pffmsqjmwfqnlpw#leva.ojmhqfif`wpbmg#vpfjnbdf!=pv``ffgeffgjmdMv`ofbqjmelqnbwl#kfosTlnfm$pMfjwkfqNf{j`bmsqlwfjm?wbaof#az#nbmzkfbowkzobtpvjwgfujpfg-svpk+xpfoofqppjnsoz#Wkqlvdk-`llhjf#Jnbdf+logfq!=vp-ip!=#Pjm`f#vmjufqpobqdfq#lsfm#wl\"..#fmgojfp#jm$^*8\u000E\t##nbqhfwtkl#jp#+!GLN@lnbmbdfglmf#elqwzsfle#Hjmdglnsqlejwpsqlslpfwl#pklt`fmwfq8nbgf#jwgqfppfgtfqf#jmnj{wvqfsqf`jpfbqjpjmdpq`#>#$nbhf#b#pf`vqfgAbswjpwulwjmd#\t\n\nubq#Nbq`k#1dqft#vs@ojnbwf-qfnlufphjoofgtbz#wkf?,kfbg=eb`f#leb`wjmd#qjdkw!=wl#tlqhqfgv`fpkbp#kbgfqf`wfgpklt+*8b`wjlm>allh#lebm#bqfb>>#!kww?kfbgfq\t?kwno=`lmelqneb`jmd#`llhjf-qfoz#lmklpwfg#-`vpwlnkf#tfmwavw#elqpsqfbg#Ebnjoz#b#nfbmplvw#wkfelqvnp-ellwbdf!=Nlajo@ofnfmwp!#jg>!bp#kjdkjmwfmpf..=?\"..efnbof#jp#pffmjnsojfgpfw#wkfb#pwbwfbmg#kjpebpwfpwafpjgfpavwwlm\\alvmgfg!=?jnd#Jmelal{fufmwp/b#zlvmdbmg#bqfMbwjuf#`kfbsfqWjnflvwbmg#kbpfmdjmfptlm#wkf+nlpwozqjdkw9#ejmg#b#.alwwlnSqjm`f#bqfb#lenlqf#lepfbq`k\\mbwvqf/ofdboozsfqjlg/obmg#lelq#tjwkjmgv`fgsqlujmdnjppjofol`boozBdbjmpwwkf#tbzh%rvlw8s{8!=\u000E\tsvpkfg#babmglmmvnfqbo@fqwbjmJm#wkjpnlqf#jmlq#plnfmbnf#jpbmg/#jm`qltmfgJPAM#3.`qfbwfpL`wlafqnbz#mlw`fmwfq#obwf#jmGfefm`ffmb`wfgtjpk#wlaqlbgoz`llojmdlmolbg>jw-#Wkfqf`lufqNfnafqpkfjdkw#bppvnfp?kwno=\tsflsof-jm#lmf#>tjmgltellwfq\\b#dllg#qfhobnblwkfqp/wl#wkjp\\`llhjfsbmfo!=Olmglm/gfejmfp`qvpkfgabswjpn`lbpwbopwbwvp#wjwof!#nluf#wlolpw#jmafwwfq#jnsojfpqjuboqzpfqufqp#PzpwfnSfqkbspfp#bmg#`lmwfmgeoltjmdobpwfg#qjpf#jmDfmfpjpujft#leqjpjmd#pffn#wlavw#jm#ab`hjmdkf#tjoodjufm#bdjujmd#`jwjfp-eolt#le#Obwfq#boo#avwKjdktbzlmoz#azpjdm#lekf#glfpgjeefqpabwwfqz%bns8obpjmdofpwkqfbwpjmwfdfqwbhf#lmqfevpfg`boofg#>VP%bnsPff#wkfmbwjufpaz#wkjppzpwfn-kfbg#le9klufq/ofpajbmpvqmbnfbmg#boo`lnnlm,kfbgfq\\\\sbqbnpKbqubqg,sj{fo-qfnlubopl#olmdqlof#leiljmwozphzp`qbVmj`lgfaq#,=\u000E\tBwobmwbmv`ofvp@lvmwz/svqfoz#`lvmw!=fbpjoz#avjog#blm`oj`hb#djufmsljmwfqk%rvlw8fufmwp#fopf#x\tgjwjlmpmlt#wkf/#tjwk#nbm#tkllqd,Tfalmf#bmg`buboqzKf#gjfgpfbwwof33/333#xtjmgltkbuf#wlje+tjmgbmg#jwpplofoz#n%rvlw8qfmftfgGfwqljwbnlmdpwfjwkfq#wkfn#jmPfmbwlqVp?,b=?Hjmd#leEqbm`jp.sqlgv`kf#vpfgbqw#bmgkjn#bmgvpfg#azp`lqjmdbw#klnfwl#kbufqfobwfpjajojwzeb`wjlmAveebolojmh!=?tkbw#kfeqff#wl@jwz#le`lnf#jmpf`wlqp`lvmwfglmf#gbzmfqulvpprvbqf#~8je+dljm#tkbwjnd!#bojp#lmozpfbq`k,wvfpgbzollpfozPlolnlmpf{vbo#.#?b#kqnfgjvn!GL#MLW#Eqbm`f/tjwk#b#tbq#bmgpf`lmg#wbhf#b#=\u000E\t\u000E\t\u000E\tnbqhfw-kjdktbzglmf#jm`wjujwz!obpw!=laojdfgqjpf#wl!vmgfejnbgf#wl#Fbqoz#sqbjpfgjm#jwp#elq#kjpbwkofwfIvsjwfqZbkll\"#wfqnfg#pl#nbmzqfbooz#p-#Wkf#b#tlnbmgjqf`w#qjdkw!#aj`z`ofb`jmd>!gbz#bmgpwbwjmdQbwkfq/kjdkfq#Leej`f#bqf#mltwjnfp/#tkfm#b#sbz#elqlm#wkjp.ojmh!=8alqgfqbqlvmg#bmmvbo#wkf#Mftsvw#wkf-`ln!#wbhjm#wlb#aqjfe+jm#wkfdqlvsp-8#tjgwkfmyznfppjnsof#jm#obwfxqfwvqmwkfqbszb#sljmwabmmjmdjmhp!=\t+*8!#qfb#sob`f_v330@bbalvw#bwq=\u000E\t\n\n``lvmw#djufp#b?P@QJSWQbjotbzwkfnfp,wlloal{AzJg+!{kvnbmp/tbw`kfpjm#plnf#je#+tj`lnjmd#elqnbwp#Vmgfq#avw#kbpkbmgfg#nbgf#azwkbm#jmefbq#legfmlwfg,jeqbnfofew#jmulowbdfjm#fb`kb%rvlw8abpf#leJm#nbmzvmgfqdlqfdjnfpb`wjlm#?,s=\u000E\t?vpwlnUb8%dw8?,jnslqwplq#wkbwnlpwoz#%bns8qf#pjyf>!?,b=?,kb#`obppsbppjufKlpw#>#TkfwkfqefqwjofUbqjlvp>X^8+ev`bnfqbp,=?,wg=b`wp#bpJm#plnf=\u000E\t\u000E\t?\"lqdbmjp#?aq#,=Afjijmd`bwbo/Lgfvwp`kfvqlsfvfvphbqbdbfjodfpufmphbfpsb/]bnfmpbifvpvbqjlwqbabiln/E{j`ls/Mdjmbpjfnsqfpjpwfnbl`wvaqfgvqbmwfb/]bgjqfnsqfpbnlnfmwlmvfpwqlsqjnfqbwqbu/Epdqb`jbpmvfpwqbsql`fplfpwbglp`bojgbgsfqplmbm/Vnfqlb`vfqgln/Vpj`bnjfnaqllefqwbpbodvmlpsb/Apfpfifnsolgfqf`klbgfn/Mpsqjubglbdqfdbqfmob`fpslpjaofklwfofppfujoobsqjnfql/Vowjnlfufmwlpbq`kjul`vowvqbnvifqfpfmwqbgbbmvm`jlfnabqdlnfq`bgldqbmgfpfpwvgjlnfilqfpefaqfqlgjpf/]lwvqjpnl`/_gjdlslqwbgbfpsb`jlebnjojbbmwlmjlsfqnjwfdvbqgbqbodvmbpsqf`jlpbodvjfmpfmwjglujpjwbpw/Awvol`lml`fqpfdvmgl`lmpfileqbm`jbnjmvwlppfdvmgbwfmfnlpfef`wlpn/Mobdbpfpj/_mqfujpwbdqbmbgb`lnsqbqjmdqfpldbq`/Abb``j/_mf`vbglqrvjfmfpjm`ovplgfafq/Mnbwfqjbklnaqfpnvfpwqbslgq/Abnb/]bmb/Vowjnbfpwbnlplej`jbowbnajfmmjmd/Vmpbovglpslgfnlpnfilqbqslpjwjlmavpjmfppklnfsbdfpf`vqjwzobmdvbdfpwbmgbqg`bnsbjdmefbwvqfp`bwfdlqzf{wfqmbo`kjogqfmqfpfqufgqfpfbq`kf{`kbmdfebulqjwfwfnsobwfnjojwbqzjmgvpwqzpfquj`fpnbwfqjbosqlgv`wpy.jmgf{9`lnnfmwpplewtbqf`lnsofwf`bofmgbqsobwelqnbqwj`ofpqfrvjqfgnlufnfmwrvfpwjlmavjogjmdslojwj`pslppjaofqfojdjlmskzpj`boeffgab`hqfdjpwfqsj`wvqfpgjpbaofgsqlwl`lobvgjfm`fpfwwjmdpb`wjujwzfofnfmwpofbqmjmdbmzwkjmdbapwqb`wsqldqfpplufqujftnbdbyjmff`lmlnj`wqbjmjmdsqfppvqfubqjlvp#?pwqlmd=sqlsfqwzpklssjmdwldfwkfqbgubm`fgafkbujlqgltmolbgefbwvqfgellwaboopfof`wfgObmdvbdfgjpwbm`fqfnfnafqwqb`hjmdsbpptlqgnlgjejfgpwvgfmwpgjqf`wozejdkwjmdmlqwkfqmgbwbabpfefpwjuboaqfbhjmdol`bwjlmjmwfqmfwgqlsgltmsqb`wj`ffujgfm`fevm`wjlmnbqqjbdfqfpslmpfsqlaofnpmfdbwjufsqldqbnpbmbozpjpqfofbpfgabmmfq!=svq`kbpfsloj`jfpqfdjlmbo`qfbwjufbqdvnfmwallhnbqhqfefqqfq`kfnj`bogjujpjlm`booab`hpfsbqbwfsqlif`wp`lmeoj`wkbqgtbqfjmwfqfpwgfojufqznlvmwbjmlawbjmfg>#ebopf8elq+ubq#b``fswfg`bsb`jwz`lnsvwfqjgfmwjwzbjq`qbewfnsolzfgsqlslpfgglnfpwj`jm`ovgfpsqlujgfgklpsjwboufqwj`bo`loobspfbssqlb`ksbqwmfqpoldl!=?bgbvdkwfqbvwklq!#`vowvqboebnjojfp,jnbdfp,bppfnaozsltfqevowfb`kjmdejmjpkfggjpwqj`w`qjwj`bo`dj.ajm,svqslpfpqfrvjqfpfof`wjlmaf`lnjmdsqlujgfpb`bgfnj`f{fq`jpfb`wvbooznfgj`jmf`lmpwbmwb``jgfmwNbdbyjmfgl`vnfmwpwbqwjmdalwwln!=lapfqufg9#%rvlw8f{wfmgfgsqfujlvpPlewtbqf`vpwlnfqgf`jpjlmpwqfmdwkgfwbjofgpojdkwozsobmmjmdwf{wbqfb`vqqfm`zfufqzlmfpwqbjdkwwqbmpefqslpjwjufsqlgv`fgkfqjwbdfpkjssjmdbaplovwfqf`fjufgqfofubmwavwwlm!#ujlofm`fbmztkfqfafmfejwpobvm`kfgqf`fmwozboojbm`felooltfgnvowjsofavoofwjmjm`ovgfgl``vqqfgjmwfqmbo'+wkjp*-qfsvaoj`=?wq=?wg`lmdqfppqf`lqgfgvowjnbwfplovwjlm?vo#jg>!gjp`lufqKlnf?,b=tfapjwfpmfwtlqhpbowklvdkfmwjqfoznfnlqjbonfppbdfp`lmwjmvfb`wjuf!=plnftkbwuj`wlqjbTfpwfqm##wjwof>!Ol`bwjlm`lmwqb`wujpjwlqpGltmolbgtjwklvw#qjdkw!=\tnfbpvqfptjgwk#>#ubqjbaofjmuloufgujqdjmjbmlqnboozkbssfmfgb``lvmwppwbmgjmdmbwjlmboQfdjpwfqsqfsbqfg`lmwqlopb``vqbwfajqwkgbzpwqbwfdzleej`jbodqbskj`p`qjnjmboslppjaoz`lmpvnfqSfqplmbopsfbhjmdubojgbwfb`kjfufg-isd!#,=nb`kjmfp?,k1=\t##hfztlqgpeqjfmgozaqlwkfqp`lnajmfglqjdjmbo`lnslpfgf{sf`wfgbgfrvbwfsbhjpwbmeloolt!#ubovbaof?,obafo=qfobwjufaqjmdjmdjm`qfbpfdlufqmlqsovdjmp,Ojpw#le#Kfbgfq!=!#mbnf>!#+%rvlw8dqbgvbwf?,kfbg=\t`lnnfq`fnbobzpjbgjqf`wlqnbjmwbjm8kfjdkw9p`kfgvof`kbmdjmdab`h#wl#`bwkloj`sbwwfqmp`lolq9# dqfbwfpwpvssojfpqfojbaof?,vo=\t\n\n?pfof`w#`jwjyfmp`olwkjmdtbw`kjmd?oj#jg>!psf`jej``bqqzjmdpfmwfm`f?`fmwfq=`lmwqbpwwkjmhjmd`bw`k+f*plvwkfqmNj`kbfo#nfq`kbmw`bqlvpfosbggjmd9jmwfqjlq-psojw+!ojybwjlmL`wlafq#*xqfwvqmjnsqlufg..%dw8\t\t`lufqbdf`kbjqnbm-smd!#,=pvaif`wpQj`kbqg#tkbwfufqsqlabaozqf`lufqzabpfabooivgdnfmw`lmmf`w--`pp!#,=#tfapjwfqfslqwfggfebvow!,=?,b=\u000E\tfof`wqj`p`lwobmg`qfbwjlmrvbmwjwz-#JPAM#3gjg#mlw#jmpwbm`f.pfbq`k.!#obmd>!psfbhfqp@lnsvwfq`lmwbjmpbq`kjufpnjmjpwfqqfb`wjlmgjp`lvmwJwbojbml`qjwfqjbpwqlmdoz9#$kwws9$p`qjsw$`lufqjmdleefqjmdbssfbqfgAqjwjpk#jgfmwjezEb`fallhmvnfqlvpufkj`ofp`lm`fqmpBnfqj`bmkbmgojmdgju#jg>!Tjoojbn#sqlujgfq\\`lmwfmwb``vqb`zpf`wjlm#bmgfqplmeof{jaof@bwfdlqzobtqfm`f?p`qjsw=obzlvw>!bssqlufg#nb{jnvnkfbgfq!=?,wbaof=Pfquj`fpkbnjowlm`vqqfmw#`bmbgjbm`kbmmfop,wkfnfp,,bqwj`oflswjlmboslqwvdboubovf>!!jmwfqubotjqfofppfmwjwofgbdfm`jfpPfbq`k!#nfbpvqfgwklvpbmgpsfmgjmd%kfoojs8mft#Gbwf!#pjyf>!sbdfMbnfnjggof!#!#,=?,b=kjggfm!=pfrvfm`fsfqplmbolufqeoltlsjmjlmpjoojmljpojmhp!=\t\n?wjwof=ufqpjlmppbwvqgbzwfqnjmbojwfnsqlsfmdjmffqpf`wjlmpgfpjdmfqsqlslpbo>!ebopf!Fpsb/]loqfofbpfppvanjw!#fq%rvlw8bggjwjlmpznswlnplqjfmwfgqfplvq`fqjdkw!=?sofbpvqfpwbwjlmpkjpwlqz-ofbujmd##alqgfq>`lmwfmwp`fmwfq!=-\t\tPlnf#gjqf`wfgpvjwbaofavodbqjb-pklt+*8gfpjdmfgDfmfqbo#`lm`fswpF{bnsofptjoojbnpLqjdjmbo!=?psbm=pfbq`k!=lsfqbwlqqfrvfpwpb#%rvlw8booltjmdGl`vnfmwqfujpjlm-#\t\tWkf#zlvqpfoe@lmwb`w#nj`kjdbmFmdojpk#`lovnajbsqjlqjwzsqjmwjmdgqjmhjmdeb`jojwzqfwvqmfg@lmwfmw#leej`fqpQvppjbm#dfmfqbwf.;;6:.2!jmgj`bwfebnjojbq#rvbojwznbqdjm93#`lmwfmwujftslqw`lmwb`wp.wjwof!=slqwbaof-ofmdwk#fojdjaofjmuloufpbwobmwj`lmolbg>!gfebvow-pvssojfgsbznfmwpdolppbqz\t\tBewfq#dvjgbm`f?,wg=?wgfm`lgjmdnjggof!=`bnf#wl#gjpsobzpp`lwwjpkilmbwkbmnbilqjwztjgdfwp-`ojmj`bowkbjobmgwfb`kfqp?kfbg=\t\nbeef`wfgpvsslqwpsljmwfq8wlPwqjmd?,pnboo=lhobklnbtjoo#af#jmufpwlq3!#bow>!klojgbzpQfplvq`foj`fmpfg#+tkj`k#-#Bewfq#`lmpjgfqujpjwjmdf{solqfqsqjnbqz#pfbq`k!#bmgqljg!rvj`hoz#nffwjmdpfpwjnbwf8qfwvqm#8`lolq9 #kfjdkw>bssqlubo/#%rvlw8#`kf`hfg-njm-ip!nbdmfwj`=?,b=?,kelqf`bpw-#Tkjof#wkvqpgbzgufqwjpf%fb`vwf8kbp@obppfubovbwflqgfqjmdf{jpwjmdsbwjfmwp#Lmojmf#`lolqbglLswjlmp!`bnsafoo?\"..#fmg?,psbm=??aq#,=\u000E\t\\slsvsp\u007Fp`jfm`fp/%rvlw8#rvbojwz#Tjmgltp#bppjdmfgkfjdkw9#?a#`obppof%rvlw8#ubovf>!#@lnsbmzf{bnsofp?jeqbnf#afojfufpsqfpfmwpnbqpkboosbqw#le#sqlsfqoz*-\t\tWkf#wb{lmlnznv`k#le#?,psbm=\t!#gbwb.pqwvdv/Fpp`qlooWl#sqlif`w?kfbg=\u000E\tbwwlqmfzfnskbpjppslmplqpebm`zal{tlqog$p#tjogojef`kf`hfg>pfppjlmpsqldqbnns{8elmw.#Sqlif`wilvqmbopafojfufgub`bwjlmwklnsplmojdkwjmdbmg#wkf#psf`jbo#alqgfq>3`kf`hjmd?,walgz=?avwwlm#@lnsofwf`ofbqej{\t?kfbg=\tbqwj`of#?pf`wjlmejmgjmdpqlof#jm#slsvobq##L`wlafqtfapjwf#f{slpvqfvpfg#wl##`kbmdfplsfqbwfg`oj`hjmdfmwfqjmd`lnnbmgpjmelqnfg#mvnafqp##?,gju=`qfbwjmdlmPvanjwnbqzobmg`loofdfpbmbozwj`ojpwjmdp`lmwb`w-olddfgJmbgujplqzpjaojmdp`lmwfmw!p%rvlw8*p-#Wkjp#sb`hbdfp`kf`hal{pvddfpwpsqfdmbmwwlnlqqltpsb`jmd>j`lm-smdibsbmfpf`lgfabpfavwwlm!=dbnaojmdpv`k#bp#/#tkjof#?,psbm=#njpplvqjpslqwjmdwls92s{#-?,psbm=wfmpjlmptjgwk>!1obyzolbgmlufnafqvpfg#jm#kfjdkw>!`qjsw!=\t%maps8?,?wq=?wg#kfjdkw91,sqlgv`w`lvmwqz#jm`ovgf#ellwfq!#%ow8\"..#wjwof!=?,irvfqz-?,elqn=\t+\u000BBl\bQ\u007F*+\u000BUm\u0005Gx*kqubwphjjwbojbmlqln/Nm(ow/Pqh/Kf4K4]4C5dwbnaj/Emmlwj`jbpnfmpbifpsfqplmbpgfqf`klpmb`jlmbopfquj`jl`lmwb`wlvpvbqjlpsqldqbnbdlajfqmlfnsqfpbpbmvm`jlpubofm`jb`lolnajbgfpsv/Epgfslqwfpsqlzf`wlsqlgv`wls/Vaoj`lmlplwqlpkjpwlqjbsqfpfmwfnjoolmfpnfgjbmwfsqfdvmwbbmwfqjlqqf`vqplpsqlaofnbpbmwjbdlmvfpwqlplsjmj/_mjnsqjnjqnjfmwqbpbn/Eqj`bufmgfglqpl`jfgbgqfpsf`wlqfbojybqqfdjpwqlsbobaqbpjmwfq/Epfmwlm`fpfpsf`jbonjfnaqlpqfbojgbg`/_qglabybqbdlybs/Mdjmbppl`jbofpaolrvfbqdfpwj/_mborvjofqpjpwfnbp`jfm`jbp`lnsofwlufqpj/_m`lnsofwbfpwvgjlps/Vaoj`blaifwjulboj`bmwfavp`bglq`bmwjgbgfmwqbgbpb``jlmfpbq`kjulppvsfqjlqnbzlq/Abbofnbmjbevm`j/_m/Vowjnlpkb`jfmglbrvfoolpfgj`j/_mefqmbmglbnajfmwfeb`fallhmvfpwqbp`ojfmwfpsql`fplpabpwbmwfsqfpfmwbqfslqwbq`lmdqfplsvaoj`bq`lnfq`jl`lmwqbwli/_ufmfpgjpwqjwlw/E`mj`b`lmivmwlfmfqd/Abwqbabibqbpwvqjbpqf`jfmwfvwjojybqalofw/Ampboubglq`lqqf`wbwqbabilpsqjnfqlpmfdl`jlpojafqwbggfwboofpsbmwboobsq/_{jnlbonfq/Abbmjnbofprvj/Emfp`lqby/_mpf``j/_mavp`bmglls`jlmfpf{wfqjlq`lm`fswlwlgbu/Abdbofq/Abfp`qjajqnfgj`jmboj`fm`jb`lmpvowbbpsf`wlp`q/Awj`bg/_obqfpivpwj`jbgfafq/Mmsfq/Alglmf`fpjwbnbmwfmfqsfrvf/]lqf`jajgbwqjavmbowfmfqjef`bm`j/_m`bmbqjbpgfp`bqdbgjufqplpnboolq`bqfrvjfqfw/E`mj`lgfafq/Abujujfmgbejmbmybpbgfobmwfevm`jlmb`lmpfilpgje/A`jo`jvgbgfpbmwjdvbpbubmybgbw/Eqnjmlvmjgbgfpp/Mm`kfy`bnsb/]bplewlmj`qfujpwbp`lmwjfmfpf`wlqfpnlnfmwlpeb`vowbg`q/Egjwlgjufqpbppvsvfpwleb`wlqfppfdvmglpsfrvf/]b<_!?,pfof`w=Bvpwqbojb!#`obpp>!pjwvbwjlmbvwklqjwzelooltjmdsqjnbqjozlsfqbwjlm`kboofmdfgfufolsfgbmlmznlvpevm`wjlm#evm`wjlmp`lnsbmjfppwqv`wvqfbdqffnfmw!#wjwof>!slwfmwjbofgv`bwjlmbqdvnfmwppf`lmgbqz`lszqjdkwobmdvbdfpf{`ovpjuf`lmgjwjlm?,elqn=\u000E\tpwbwfnfmwbwwfmwjlmAjldqbskz~#fopf#x\tplovwjlmptkfm#wkf#Bmbozwj`pwfnsobwfpgbmdfqlvppbwfoojwfgl`vnfmwpsvaojpkfqjnslqwbmwsqlwlwzsfjmeovfm`f%qbrvl8?,feef`wjufdfmfqboozwqbmpelqnafbvwjevowqbmpslqwlqdbmjyfgsvaojpkfgsqlnjmfmwvmwjo#wkfwkvnambjoMbwjlmbo#-el`vp+*8lufq#wkf#njdqbwjlmbmmlvm`fgellwfq!=\tf{`fswjlmofpp#wkbmf{sfmpjufelqnbwjlmeqbnftlqhwfqqjwlqzmgj`bwjlm`vqqfmwoz`obppMbnf`qjwj`jpnwqbgjwjlmfopftkfqfBof{bmgfqbssljmwfgnbwfqjbopaqlbg`bpwnfmwjlmfgbeejojbwf?,lswjlm=wqfbwnfmwgjeefqfmw,gfebvow-Sqfpjgfmwlm`oj`h>!ajldqbskzlwkfqtjpfsfqnbmfmwEqbm/KbjpKlooztllgf{sbmpjlmpwbmgbqgp?,pwzof=\tqfgv`wjlmGf`fnafq#sqfefqqfg@bnaqjgdflsslmfmwpAvpjmfpp#`lmevpjlm=\t?wjwof=sqfpfmwfgf{sobjmfgglfp#mlw#tlqogtjgfjmwfqeb`fslpjwjlmpmftpsbsfq?,wbaof=\tnlvmwbjmpojhf#wkf#fppfmwjboejmbm`jbopfof`wjlmb`wjlm>!,babmglmfgFgv`bwjlmsbqpfJmw+pwbajojwzvmbaof#wl?,wjwof=\tqfobwjlmpMlwf#wkbwfeej`jfmwsfqelqnfgwtl#zfbqpPjm`f#wkfwkfqfelqftqbssfq!=bowfqmbwfjm`qfbpfgAbwwof#lesfq`fjufgwqzjmd#wlmf`fppbqzslqwqbzfgfof`wjlmpFojybafwk?,jeqbnf=gjp`lufqzjmpvqbm`fp-ofmdwk8ofdfmgbqzDfldqbskz`bmgjgbwf`lqslqbwfplnfwjnfppfquj`fp-jmkfqjwfg?,pwqlmd=@lnnvmjwzqfojdjlvpol`bwjlmp@lnnjwwffavjogjmdpwkf#tlqogml#olmdfqafdjmmjmdqfefqfm`f`bmmlw#afeqfrvfm`zwzsj`boozjmwl#wkf#qfobwjuf8qf`lqgjmdsqfpjgfmwjmjwjboozwf`kmjrvfwkf#lwkfqjw#`bm#aff{jpwfm`fvmgfqojmfwkjp#wjnfwfofsklmfjwfnp`lsfsqb`wj`fpbgubmwbdf*8qfwvqm#Elq#lwkfqsqlujgjmdgfnl`qb`zalwk#wkf#f{wfmpjufpveefqjmdpvsslqwfg`lnsvwfqp#evm`wjlmsqb`wj`bopbjg#wkbwjw#nbz#afFmdojpk?,eqln#wkf#p`kfgvofggltmolbgp?,obafo=\tpvpsf`wfgnbqdjm9#3psjqjwvbo?,kfbg=\t\tnj`qlplewdqbgvboozgjp`vppfgkf#af`bnff{f`vwjufirvfqz-ipklvpfklog`lmejqnfgsvq`kbpfgojwfqboozgfpwqlzfgvs#wl#wkfubqjbwjlmqfnbjmjmdjw#jp#mlw`fmwvqjfpIbsbmfpf#bnlmd#wkf`lnsofwfgbodlqjwknjmwfqfpwpqfafoojlmvmgfejmfgfm`lvqbdfqfpjybaofjmuloujmdpfmpjwjufvmjufqpbosqlujpjlm+bowklvdkefbwvqjmd`lmgv`wfg*/#tkj`k#`lmwjmvfg.kfbgfq!=Efaqvbqz#mvnfqlvp#lufqeolt9`lnslmfmweqbdnfmwpf{`foofmw`lopsbm>!wf`kmj`bomfbq#wkf#Bgubm`fg#plvq`f#lef{sqfppfgKlmd#Hlmd#Eb`fallhnvowjsof#nf`kbmjpnfofubwjlmleefmpjuf?,elqn=\t\npslmplqfggl`vnfmw-lq#%rvlw8wkfqf#bqfwklpf#tklnlufnfmwpsql`fppfpgjeej`vowpvanjwwfgqf`lnnfmg`lmujm`fgsqlnlwjmd!#tjgwk>!-qfsob`f+`obppj`bo`lbojwjlmkjp#ejqpwgf`jpjlmpbppjpwbmwjmgj`bwfgfulovwjlm.tqbssfq!fmlvdk#wlbolmd#wkfgfojufqfg..=\u000E\t?\"..Bnfqj`bm#sqlwf`wfgMlufnafq#?,pwzof=?evqmjwvqfJmwfqmfw##lmaovq>!pvpsfmgfgqf`jsjfmwabpfg#lm#Nlqflufq/balojpkfg`loof`wfgtfqf#nbgffnlwjlmbofnfqdfm`zmbqqbwjufbgul`bwfps{8alqgfq`lnnjwwfggjq>!owq!fnsolzffpqfpfbq`k-#pfof`wfgpv``fpplq`vpwlnfqpgjpsobzfgPfswfnafqbgg@obpp+Eb`fallh#pvddfpwfgbmg#obwfqlsfqbwjmdfobalqbwfPlnfwjnfpJmpwjwvwf`fqwbjmozjmpwboofgelooltfqpIfqvpbofnwkfz#kbuf`lnsvwjmddfmfqbwfgsqlujm`fpdvbqbmwffbqajwqbqzqf`ldmjyftbmwfg#wls{8tjgwk9wkflqz#leafkbujlvqTkjof#wkffpwjnbwfgafdbm#wl#jw#af`bnfnbdmjwvgfnvpw#kbufnlqf#wkbmGjqf`wlqzf{wfmpjlmpf`qfwbqzmbwvqboozl``vqqjmdubqjbaofpdjufm#wkfsobwelqn-?,obafo=?ebjofg#wl`lnslvmgphjmgp#le#pl`jfwjfpbolmdpjgf#..%dw8\t\tplvwktfpwwkf#qjdkwqbgjbwjlmnbz#kbuf#vmfp`bsf+pslhfm#jm!#kqfe>!,sqldqbnnflmoz#wkf#`lnf#eqlngjqf`wlqzavqjfg#jmb#pjnjobqwkfz#tfqf?,elmw=?,Mlqtfdjbmpsf`jejfgsqlgv`jmdsbppfmdfq+mft#Gbwfwfnslqbqzej`wjlmboBewfq#wkffrvbwjlmpgltmolbg-qfdvobqozgfufolsfqbaluf#wkfojmhfg#wlskfmlnfmbsfqjlg#lewllowjs!=pvapwbm`fbvwlnbwj`bpsf`w#leBnlmd#wkf`lmmf`wfgfpwjnbwfpBjq#Elq`fpzpwfn#lelaif`wjufjnnfgjbwfnbhjmd#jwsbjmwjmdp`lmrvfqfgbqf#pwjoosql`fgvqfdqltwk#lekfbgfg#azFvqlsfbm#gjujpjlmpnlof`vofpeqbm`kjpfjmwfmwjlmbwwqb`wfg`kjogkllgbopl#vpfggfgj`bwfgpjmdbslqfgfdqff#leebwkfq#le`lmeoj`wp?,b=?,s=\t`bnf#eqlntfqf#vpfgmlwf#wkbwqf`fjujmdF{f`vwjuffufm#nlqfb``fpp#wl`lnnbmgfqSlojwj`bonvpj`jbmpgfoj`jlvpsqjplmfqpbgufmw#leVWE.;!#,=?\"X@GBWBX!=@lmwb`wPlvwkfqm#ad`lolq>!pfqjfp#le-#Jw#tbp#jm#Fvqlsfsfqnjwwfgubojgbwf-bssfbqjmdleej`jboppfqjlvpoz.obmdvbdfjmjwjbwfgf{wfmgjmdolmd.wfqnjmeobwjlmpv`k#wkbwdfw@llhjfnbqhfg#az?,avwwlm=jnsofnfmwavw#jw#jpjm`qfbpfpgltm#wkf#qfrvjqjmdgfsfmgfmw..=\t?\"..#jmwfqujftTjwk#wkf#`lsjfp#le`lmpfmpvptbp#avjowUfmfyvfob+elqnfqozwkf#pwbwfsfqplmmfopwqbwfdj`ebulvq#lejmufmwjlmTjhjsfgjb`lmwjmfmwujqwvbooztkj`k#tbpsqjm`jsof@lnsofwf#jgfmwj`bopklt#wkbwsqjnjwjufbtbz#eqlnnlof`vobqsqf`jpfozgjpploufgVmgfq#wkfufqpjlm>!=%maps8?,Jw#jp#wkf#Wkjp#jp#tjoo#kbuflqdbmjpnpplnf#wjnfEqjfgqj`ktbp#ejqpwwkf#lmoz#eb`w#wkbwelqn#jg>!sqf`fgjmdWf`kmj`boskzpj`jpwl``vqp#jmmbujdbwlqpf`wjlm!=psbm#jg>!plvdkw#wlafolt#wkfpvqujujmd~?,pwzof=kjp#gfbwkbp#jm#wkf`bvpfg#azsbqwjboozf{jpwjmd#vpjmd#wkftbp#djufmb#ojpw#leofufop#lemlwjlm#leLeej`jbo#gjpnjppfgp`jfmwjpwqfpfnaofpgvsoj`bwff{solpjufqf`lufqfgboo#lwkfqdboofqjfpxsbggjmd9sflsof#leqfdjlm#lebggqfppfpbppl`jbwfjnd#bow>!jm#nlgfqmpklvog#afnfwklg#leqfslqwjmdwjnfpwbnsmffgfg#wlwkf#Dqfbwqfdbqgjmdpffnfg#wlujftfg#bpjnsb`w#lmjgfb#wkbwwkf#Tlqogkfjdkw#lef{sbmgjmdWkfpf#bqf`vqqfmw!=`bqfevooznbjmwbjmp`kbqdf#le@obppj`bobggqfppfgsqfgj`wfgltmfqpkjs?gju#jg>!qjdkw!=\u000E\tqfpjgfm`fofbuf#wkf`lmwfmw!=bqf#lewfm##~*+*8\u000E\tsqlabaoz#Sqlefpplq.avwwlm!#qfpslmgfgpbzp#wkbwkbg#wl#afsob`fg#jmKvmdbqjbmpwbwvp#lepfqufp#bpVmjufqpbof{f`vwjlmbddqfdbwfelq#tkj`kjmef`wjlmbdqffg#wlkltfufq/#slsvobq!=sob`fg#lm`lmpwqv`wfof`wlqbopznalo#lejm`ovgjmdqfwvqm#wlbq`kjwf`w@kqjpwjbmsqfujlvp#ojujmd#jmfbpjfq#wlsqlefpplq\t%ow8\"..#feef`w#lebmbozwj`ptbp#wbhfmtkfqf#wkfwllh#lufqafojfe#jmBeqjhbbmpbp#ebq#bpsqfufmwfgtlqh#tjwkb#psf`jbo?ejfogpfw@kqjpwnbpQfwqjfufg\t\tJm#wkf#ab`h#jmwlmlqwkfbpwnbdbyjmfp=?pwqlmd=`lnnjwwffdlufqmjmddqlvsp#lepwlqfg#jmfpwbaojpkb#dfmfqbojwp#ejqpwwkfjq#ltmslsvobwfgbm#laif`w@bqjaafbmboolt#wkfgjpwqj`wptjp`lmpjmol`bwjlm-8#tjgwk9#jmkbajwfgPl`jbojpwIbmvbqz#2?,ellwfq=pjnjobqoz`klj`f#lewkf#pbnf#psf`jej`#avpjmfpp#Wkf#ejqpw-ofmdwk8#gfpjqf#wlgfbo#tjwkpjm`f#wkfvpfqBdfmw`lm`fjufgjmgf{-sksbp#%rvlw8fmdbdf#jmqf`fmwoz/eft#zfbqptfqf#bopl\t?kfbg=\t?fgjwfg#azbqf#hmltm`jwjfp#jmb``fpphfz`lmgfnmfgbopl#kbufpfquj`fp/ebnjoz#leP`kllo#le`lmufqwfgmbwvqf#le#obmdvbdfnjmjpwfqp?,laif`w=wkfqf#jp#b#slsvobqpfrvfm`fpbgul`bwfgWkfz#tfqfbmz#lwkfqol`bwjlm>fmwfq#wkfnv`k#nlqfqfeof`wfgtbp#mbnfglqjdjmbo#b#wzsj`botkfm#wkfzfmdjmffqp`lvog#mlwqfpjgfmwptfgmfpgbzwkf#wkjqg#sqlgv`wpIbmvbqz#1tkbw#wkfzb#`fqwbjmqfb`wjlmpsql`fpplqbewfq#kjpwkf#obpw#`lmwbjmfg!=?,gju=\t?,b=?,wg=gfsfmg#lmpfbq`k!=\tsjf`fp#le`lnsfwjmdQfefqfm`fwfmmfppfftkj`k#kbp#ufqpjlm>?,psbm=#??,kfbgfq=djufp#wkfkjpwlqjbmubovf>!!=sbggjmd93ujft#wkbwwldfwkfq/wkf#nlpw#tbp#elvmgpvapfw#lebwwb`h#lm`kjogqfm/sljmwp#lesfqplmbo#slpjwjlm9boofdfgoz@ofufobmgtbp#obwfqbmg#bewfqbqf#djufmtbp#pwjoop`qloojmdgfpjdm#lenbhfp#wkfnv`k#ofppBnfqj`bmp-\t\tBewfq#/#avw#wkfNvpfvn#leolvjpjbmb+eqln#wkfnjmmfplwbsbqwj`ofpb#sql`fppGlnjmj`bmulovnf#leqfwvqmjmdgfefmpjuf33s{\u007Fqjdknbgf#eqlnnlvpflufq!#pwzof>!pwbwfp#le+tkj`k#jp`lmwjmvfpEqbm`jp`lavjogjmd#tjwklvw#btjwk#plnftkl#tlvogb#elqn#leb#sbqw#leafelqf#jwhmltm#bp##Pfquj`fpol`bwjlm#bmg#lewfmnfbpvqjmdbmg#jw#jpsbsfqab`hubovfp#le\u000E\t?wjwof=>#tjmglt-gfwfqnjmffq%rvlw8#sobzfg#azbmg#fbqoz?,`fmwfq=eqln#wkjpwkf#wkqffsltfq#bmgle#%rvlw8jmmfqKWNO?b#kqfe>!z9jmojmf8@kvq`k#lewkf#fufmwufqz#kjdkleej`jbo#.kfjdkw9#`lmwfmw>!,`dj.ajm,wl#`qfbwfbeqjhbbmpfpsfqbmwleqbm/Kbjpobwujf)Mvojfwvuj)_(`f)Mwjmb(af)Mwjmb\fUh\fT{\fTN\n{I\np@\u0004Fr\u000BBl\bQ\u007F\tA{\u000BUm\u0005Gx\tA{\u0001yp\u0006YA\u0000zX\bTV\bWl\bUd\u0004BM\u000BB{\npV\u000B@x\u0004B\\\np@\u0004Db\u0004Gz\tal\npa\tfM\tuD\bV~\u0004mx\u000BQ}\ndS\tp\\\bVK\bS]\bU|\u0005oD\tkV\u000Bed\u000BHR\nb~\u0004M`\nJp\u0005oD\u0004|Q\nLP\u0004Sw\bTl\nAI\nxC\bWt\tBq\u0005F`\u0004Cm\u000BLm\tKx\t}t\bPv\ny\\\naB\tV\u007F\nZd\u0004XU\u0004li\tfr\ti@\tBH\u0004BD\u0004BV\t`V\n[]\tp_\tTn\n~A\nxR\tuD\t`{\bV@\tTn\tHK\tAJ\u000Bxs\u0004Zf\nqI\u0004Zf\u000BBM\u000B|j\t}t\bSM\nmC\u000BQ}pfquj`jlpbqw/A`volbqdfmwjmbabq`folmb`vborvjfqsvaoj`bglsqlgv`wlpslo/Awj`bqfpsvfpwbtjhjsfgjbpjdvjfmwfa/Vprvfgb`lnvmjgbgpfdvqjgbgsqjm`jsbosqfdvmwbp`lmwfmjglqfpslmgfqufmfyvfobsqlaofnbpgj`jfnaqfqfob`j/_mmlujfnaqfpjnjobqfpsqlzf`wlpsqldqbnbpjmpwjwvwlb`wjujgbgfm`vfmwqbf`lmln/Abjn/Mdfmfp`lmwb`wbqgfp`bqdbqmf`fpbqjlbwfm`j/_mwfo/Eelml`lnjpj/_m`bm`jlmfp`bsb`jgbgfm`lmwqbqbm/Mojpjpebulqjwlpw/Eqnjmlpsqlujm`jbfwjrvfwbpfofnfmwlpevm`jlmfpqfpvowbgl`bq/M`wfqsqlsjfgbgsqjm`jsjlmf`fpjgbgnvmj`jsbo`qfb`j/_mgfp`bqdbpsqfpfm`jb`lnfq`jbolsjmjlmfpfifq`j`jlfgjwlqjbopbobnbm`bdlmy/Mofygl`vnfmwlsfo/A`vobqf`jfmwfpdfmfqbofpwbqqbdlmbsq/M`wj`bmlufgbgfpsqlsvfpwbsb`jfmwfpw/E`mj`bplaifwjulp`lmwb`wlp\fHB\fIk\fHn\fH^\fHS\fHc\fHU\fId\fHn\fH{\fHC\fHR\fHT\fHR\fHI\fHc\fHY\fHn\fH\\\fHU\fIk\fHy\fIg\fHd\fHy\fIm\fHw\fH\\\fHU\fHR\fH@\fHR\fHJ\fHy\fHU\fHR\fHT\fHA\fIl\fHU\fIm\fHc\fH\\\fHU\fIl\fHB\fId\fHn\fHJ\fHS\fHD\fH@\fHR\fHHgjsolgl`p\fHT\fHB\fHC\fH\\\fIn\fHF\fHD\fHR\fHB\fHF\fHH\fHR\fHG\fHS\fH\\\fHx\fHT\fHH\fHH\fH\\\fHU\fH^\fIg\fH{\fHU\fIm\fHj\fH@\fHR\fH\\\fHJ\fIk\fHZ\fHU\fIm\fHd\fHz\fIk\fH^\fHC\fHJ\fHS\fHy\fHR\fHB\fHY\fIk\fH@\fHH\fIl\fHD\fH@\fIl\fHv\fHB\fI`\fHH\fHT\fHR\fH^\fH^\fIk\fHz\fHp\fIe\fH@\fHB\fHJ\fHJ\fHH\fHI\fHR\fHD\fHU\fIl\fHZ\fHU\fH\\\fHi\fH^\fH{\fHy\fHA\fIl\fHD\fH{\fH\\\fHF\fHR\fHT\fH\\\fHR\fHH\fHy\fHS\fHc\fHe\fHT\fIk\fH{\fHC\fIl\fHU\fIn\fHm\fHj\fH{\fIk\fHs\fIl\fHB\fHz\fIg\fHp\fHy\fHR\fH\\\fHi\fHA\fIl\fH{\fHC\fIk\fHH\fIm\fHB\fHY\fIg\fHs\fHJ\fIk\fHn\fHi\fH{\fH\\\fH|\fHT\fIk\fHB\fIk\fH^\fH^\fH{\fHR\fHU\fHR\fH^\fHf\fHF\fH\\\fHv\fHR\fH\\\fH|\fHT\fHR\fHJ\fIk\fH\\\fHp\fHS\fHT\fHJ\fHS\fH^\fH@\fHn\fHJ\fH@\fHD\fHR\fHU\fIn\fHn\fH^\fHR\fHz\fHp\fIl\fHH\fH@\fHs\fHD\fHB\fHS\fH^\fHk\fHT\fIk\fHj\fHD\fIk\fHD\fHC\fHR\fHy\fIm\fH^\fH^\fIe\fH{\fHA\fHR\fH{\fH\\\fIk\fH^\fHp\fH{\fHU\fH\\\fHR\fHB\fH^\fH{\fIk\fHF\fIk\fHp\fHU\fHR\fHI\fHk\fHT\fIl\fHT\fHU\fIl\fHy\fH^\fHR\fHL\fIl\fHy\fHU\fHR\fHm\fHJ\fIn\fH\\\fHH\fHU\fHH\fHT\fHR\fHH\fHC\fHR\fHJ\fHj\fHC\fHR\fHF\fHR\fHy\fHy\fI`\fHD\fHZ\fHR\fHB\fHJ\fIk\fHz\fHC\fHU\fIl\fH\\\fHR\fHC\fHz\fIm\fHJ\fH^\fH{\fIl`bwfdlqjfpf{sfqjfm`f?,wjwof=\u000E\t@lszqjdkw#ibubp`qjsw`lmgjwjlmpfufqzwkjmd?s#`obpp>!wf`kmloldzab`hdqlvmg?b#`obpp>!nbmbdfnfmw%`lsz8#132ibubP`qjsw`kbqb`wfqpaqfbg`qvnawkfnpfoufpklqjylmwbodlufqmnfmw@bojelqmjbb`wjujwjfpgjp`lufqfgMbujdbwjlmwqbmpjwjlm`lmmf`wjlmmbujdbwjlmbssfbqbm`f?,wjwof=?n`kf`hal{!#wf`kmjrvfpsqlwf`wjlmbssbqfmwozbp#tfoo#bpvmw$/#$VB.qfplovwjlmlsfqbwjlmpwfofujpjlmwqbmpobwfgTbpkjmdwlmmbujdbwlq-#>#tjmglt-jnsqfppjlm%ow8aq%dw8ojwfqbwvqfslsvobwjlmad`lolq>! fpsf`jbooz#`lmwfmw>!sqlgv`wjlmmftpofwwfqsqlsfqwjfpgfejmjwjlmofbgfqpkjsWf`kmloldzSbqojbnfmw`lnsbqjplmvo#`obpp>!-jmgf{Le+!`lm`ovpjlmgjp`vppjlm`lnslmfmwpajloldj`boQfulovwjlm\\`lmwbjmfqvmgfqpwllgmlp`qjsw=?sfqnjppjlmfb`k#lwkfqbwnlpskfqf#lmel`vp>!?elqn#jg>!sql`fppjmdwkjp-ubovfdfmfqbwjlm@lmefqfm`fpvapfrvfmwtfoo.hmltmubqjbwjlmpqfsvwbwjlmskfmlnfmlmgjp`jsojmfoldl-smd!#+gl`vnfmw/alvmgbqjfpf{sqfppjlmpfwwofnfmwAb`hdqlvmglvw#le#wkffmwfqsqjpf+!kwwsp9!#vmfp`bsf+!sbpptlqg!#gfnl`qbwj`?b#kqfe>!,tqbssfq!=\tnfnafqpkjsojmdvjpwj`s{8sbggjmdskjolplskzbppjpwbm`fvmjufqpjwzeb`jojwjfpqf`ldmjyfgsqfefqfm`fje#+wzsflenbjmwbjmfgul`bavobqzkzslwkfpjp-pvanjw+*8%bns8maps8bmmlwbwjlmafkjmg#wkfElvmgbwjlmsvaojpkfq!bppvnswjlmjmwqlgv`fg`lqqvswjlmp`jfmwjpwpf{soj`jwozjmpwfbg#legjnfmpjlmp#lm@oj`h>!`lmpjgfqfggfsbqwnfmwl``vsbwjlmpllm#bewfqjmufpwnfmwsqlmlvm`fgjgfmwjejfgf{sfqjnfmwNbmbdfnfmwdfldqbskj`!#kfjdkw>!ojmh#qfo>!-qfsob`f+,gfsqfppjlm`lmefqfm`fsvmjpknfmwfojnjmbwfgqfpjpwbm`fbgbswbwjlmlsslpjwjlmtfoo#hmltmpvssofnfmwgfwfqnjmfgk2#`obpp>!3s{8nbqdjmnf`kbmj`bopwbwjpwj`p`fofaqbwfgDlufqmnfmw\t\tGvqjmd#wgfufolsfqpbqwjej`jbofrvjubofmwlqjdjmbwfg@lnnjppjlmbwwb`knfmw?psbm#jg>!wkfqf#tfqfMfgfqobmgpafzlmg#wkfqfdjpwfqfgilvqmbojpweqfrvfmwozboo#le#wkfobmd>!fm!#?,pwzof=\u000E\tbaplovwf8#pvsslqwjmdf{wqfnfoz#nbjmpwqfbn?,pwqlmd=#slsvobqjwzfnsolznfmw?,wbaof=\u000E\t#`lopsbm>!?,elqn=\t##`lmufqpjlmbalvw#wkf#?,s=?,gju=jmwfdqbwfg!#obmd>!fmSlqwvdvfpfpvapwjwvwfjmgjujgvbojnslppjaofnvowjnfgjbbonlpw#boos{#plojg# bsbqw#eqlnpvaif`w#wljm#Fmdojpk`qjwj`jyfgf{`fsw#elqdvjgfojmfplqjdjmboozqfnbqhbaofwkf#pf`lmgk1#`obpp>!?b#wjwof>!+jm`ovgjmdsbqbnfwfqpsqlkjajwfg>#!kwws9,,gj`wjlmbqzsfq`fswjlmqfulovwjlmelvmgbwjlms{8kfjdkw9pv``fppevopvsslqwfqpnjoofmmjvnkjp#ebwkfqwkf#%rvlw8ml.qfsfbw8`lnnfq`jbojmgvpwqjbofm`lvqbdfgbnlvmw#le#vmleej`jbofeej`jfm`zQfefqfm`fp`llqgjmbwfgjp`objnfqf{sfgjwjlmgfufolsjmd`bo`vobwfgpjnsojejfgofdjwjnbwfpvapwqjmd+3!#`obpp>!`lnsofwfozjoovpwqbwfejuf#zfbqpjmpwqvnfmwSvaojpkjmd2!#`obpp>!spz`kloldz`lmejgfm`fmvnafq#le#bapfm`f#leel`vpfg#lmiljmfg#wkfpwqv`wvqfpsqfujlvpoz=?,jeqbnf=lm`f#bdbjmavw#qbwkfqjnnjdqbmwple#`lvqpf/b#dqlvs#leOjwfqbwvqfVmojhf#wkf?,b=%maps8\tevm`wjlm#jw#tbp#wkf@lmufmwjlmbvwlnlajofSqlwfpwbmwbddqfppjufbewfq#wkf#Pjnjobqoz/!#,=?,gju=`loof`wjlm\u000E\tevm`wjlmujpjajojwzwkf#vpf#leulovmwffqpbwwqb`wjlmvmgfq#wkf#wkqfbwfmfg)?\"X@GBWBXjnslqwbm`fjm#dfmfqbowkf#obwwfq?,elqn=\t?,-jmgf{Le+$j#>#38#j#?gjeefqfm`fgfulwfg#wlwqbgjwjlmppfbq`k#elqvowjnbwfozwlvqmbnfmwbwwqjavwfppl.`boofg#~\t?,pwzof=fubovbwjlmfnskbpjyfgb``fppjaof?,pf`wjlm=pv``fppjlmbolmd#tjwkNfbmtkjof/jmgvpwqjfp?,b=?aq#,=kbp#af`lnfbpsf`wp#leWfofujpjlmpveej`jfmwabphfwabooalwk#pjgfp`lmwjmvjmdbm#bqwj`of?jnd#bow>!bgufmwvqfpkjp#nlwkfqnbm`kfpwfqsqjm`jsofpsbqwj`vobq`lnnfmwbqzfeef`wp#legf`jgfg#wl!=?pwqlmd=svaojpkfqpIlvqmbo#legjeej`vowzeb`jojwbwfb``fswbaofpwzof-`pp!\nevm`wjlm#jmmlubwjlm=@lszqjdkwpjwvbwjlmptlvog#kbufavpjmfppfpGj`wjlmbqzpwbwfnfmwplewfm#vpfgsfqpjpwfmwjm#Ibmvbqz`lnsqjpjmd?,wjwof=\t\ngjsolnbwj``lmwbjmjmdsfqelqnjmdf{wfmpjlmpnbz#mlw#af`lm`fsw#le#lm`oj`h>!Jw#jp#boplejmbm`jbo#nbhjmd#wkfOv{fnalvqdbggjwjlmbobqf#`boofgfmdbdfg#jm!p`qjsw!*8avw#jw#tbpfof`wqlmj`lmpvanjw>!\t?\"..#Fmg#fof`wqj`boleej`jboozpvddfpwjlmwls#le#wkfvmojhf#wkfBvpwqbojbmLqjdjmboozqfefqfm`fp\t?,kfbg=\u000E\tqf`ldmjpfgjmjwjbojyfojnjwfg#wlBof{bmgqjbqfwjqfnfmwBgufmwvqfpelvq#zfbqp\t\t%ow8\"..#jm`qfbpjmdgf`lqbwjlmk0#`obpp>!lqjdjmp#lelaojdbwjlmqfdvobwjlm`obppjejfg+evm`wjlm+bgubmwbdfpafjmd#wkf#kjpwlqjbmp?abpf#kqfeqfsfbwfgoztjoojmd#wl`lnsbqbaofgfpjdmbwfgmlnjmbwjlmevm`wjlmbojmpjgf#wkfqfufobwjlmfmg#le#wkfp#elq#wkf#bvwklqjyfgqfevpfg#wlwbhf#sob`fbvwlmlnlvp`lnsqlnjpfslojwj`bo#qfpwbvqbmwwtl#le#wkfEfaqvbqz#1rvbojwz#leptelaif`w-vmgfqpwbmgmfbqoz#bootqjwwfm#azjmwfqujftp!#tjgwk>!2tjwkgqbtboeolbw9ofewjp#vpvbooz`bmgjgbwfpmftpsbsfqpnzpwfqjlvpGfsbqwnfmwafpw#hmltmsbqojbnfmwpvssqfppfg`lmufmjfmwqfnfnafqfggjeefqfmw#pzpwfnbwj`kbp#ofg#wlsqlsbdbmgb`lmwqloofgjmeovfm`fp`fqfnlmjbosql`objnfgSqlwf`wjlmoj#`obpp>!P`jfmwjej``obpp>!ml.wqbgfnbqhpnlqf#wkbm#tjgfpsqfbgOjafqbwjlmwllh#sob`fgbz#le#wkfbp#olmd#bpjnsqjplmfgBggjwjlmbo\t?kfbg=\t?nObalqbwlqzMlufnafq#1f{`fswjlmpJmgvpwqjboubqjfwz#leeolbw9#ofeGvqjmd#wkfbppfppnfmwkbuf#affm#gfbop#tjwkPwbwjpwj`pl``vqqfm`f,vo=?,gju=`ofbqej{!=wkf#svaoj`nbmz#zfbqptkj`k#tfqflufq#wjnf/pzmlmznlvp`lmwfmw!=\tsqfpvnbaozkjp#ebnjozvpfqBdfmw-vmf{sf`wfgjm`ovgjmd#`kboofmdfgb#njmlqjwzvmgfejmfg!afolmdp#wlwbhfm#eqlnjm#L`wlafqslpjwjlm9#pbjg#wl#afqfojdjlvp#Efgfqbwjlm#qltpsbm>!lmoz#b#eftnfbmw#wkbwofg#wl#wkf..=\u000E\t?gju#?ejfogpfw=Bq`kajpkls#`obpp>!mlafjmd#vpfgbssqlb`kfpsqjujofdfpmlp`qjsw=\tqfpvowp#jmnbz#af#wkfFbpwfq#fddnf`kbmjpnpqfbplmbaofSlsvobwjlm@loof`wjlmpfof`wfg!=mlp`qjsw=\u000E,jmgf{-sksbqqjubo#le.ippgh$**8nbmbdfg#wljm`lnsofwf`bpvbowjfp`lnsofwjlm@kqjpwjbmpPfswfnafq#bqjwknfwj`sql`fgvqfpnjdkw#kbufSqlgv`wjlmjw#bssfbqpSkjolplskzeqjfmgpkjsofbgjmd#wldjujmd#wkfwltbqg#wkfdvbqbmwffggl`vnfmwfg`lolq9 333ujgfl#dbnf`lnnjppjlmqfeof`wjmd`kbmdf#wkfbppl`jbwfgpbmp.pfqjelmhfzsqfpp8#sbggjmd9Kf#tbp#wkfvmgfqozjmdwzsj`booz#/#bmg#wkf#pq`Fofnfmwpv``fppjufpjm`f#wkf#pklvog#af#mfwtlqhjmdb``lvmwjmdvpf#le#wkfoltfq#wkbmpkltp#wkbw?,psbm=\t\n\n`lnsobjmwp`lmwjmvlvprvbmwjwjfpbpwqlmlnfqkf#gjg#mlwgvf#wl#jwpbssojfg#wlbm#bufqbdffeelqwp#wlwkf#evwvqfbwwfnsw#wlWkfqfelqf/`bsbajojwzQfsvaoj`bmtbp#elqnfgFof`wqlmj`hjolnfwfqp`kboofmdfpsvaojpkjmdwkf#elqnfqjmgjdfmlvpgjqf`wjlmppvapjgjbqz`lmpsjqb`zgfwbjop#lebmg#jm#wkfbeelqgbaofpvapwbm`fpqfbplm#elq`lmufmwjlmjwfnwzsf>!baplovwfozpvsslpfgozqfnbjmfg#bbwwqb`wjufwqbufoojmdpfsbqbwfozel`vpfp#lmfofnfmwbqzbssoj`baofelvmg#wkbwpwzofpkffwnbmvp`qjswpwbmgp#elq#ml.qfsfbw+plnfwjnfp@lnnfq`jbojm#Bnfqj`bvmgfqwbhfmrvbqwfq#lebm#f{bnsofsfqplmboozjmgf{-sks!owqOjfvwfmbmw\t?gju#jg>!wkfz#tlvogbajojwz#lenbgf#vs#lemlwfg#wkbw`ofbq#wkbwbqdvf#wkbwwl#bmlwkfq`kjogqfm$psvqslpf#leelqnvobwfgabpfg#vslmwkf#qfdjlmpvaif`w#lesbppfmdfqpslppfppjlm-\t\tJm#wkf#Afelqf#wkfbewfqtbqgp`vqqfmwoz#b`qlpp#wkfp`jfmwjej``lnnvmjwz-`bsjwbojpnjm#Dfqnbmzqjdkw.tjmdwkf#pzpwfnPl`jfwz#leslojwj`jbmgjqf`wjlm9tfmw#lm#wlqfnlubo#le#Mft#Zlqh#bsbqwnfmwpjmgj`bwjlmgvqjmd#wkfvmofpp#wkfkjpwlqj`bokbg#affm#bgfejmjwjufjmdqfgjfmwbwwfmgbm`f@fmwfq#elqsqlnjmfm`fqfbgzPwbwfpwqbwfdjfpavw#jm#wkfbp#sbqw#le`lmpwjwvwf`objn#wkbwobalqbwlqz`lnsbwjaofebjovqf#le/#pv`k#bp#afdbm#tjwkvpjmd#wkf#wl#sqlujgfefbwvqf#leeqln#tkj`k,!#`obpp>!dfloldj`bopfufqbo#legfojafqbwfjnslqwbmw#klogp#wkbwjmd%rvlw8#ubojdm>wlswkf#Dfqnbmlvwpjgf#lemfdlwjbwfgkjp#`bqffqpfsbqbwjlmjg>!pfbq`ktbp#`boofgwkf#elvqwkqf`qfbwjlmlwkfq#wkbmsqfufmwjlmtkjof#wkf#fgv`bwjlm/`lmmf`wjmdb``vqbwfoztfqf#avjowtbp#hjoofgbdqffnfmwpnv`k#nlqf#Gvf#wl#wkftjgwk9#233plnf#lwkfqHjmdgln#lewkf#fmwjqfebnlvp#elqwl#`lmmf`wlaif`wjufpwkf#Eqfm`ksflsof#bmgefbwvqfg!=jp#pbjg#wlpwqv`wvqboqfefqfmgvnnlpw#lewfmb#pfsbqbwf.=\t?gju#jg#Leej`jbo#tlqogtjgf-bqjb.obafowkf#sobmfwbmg#jw#tbpg!#ubovf>!ollhjmd#bwafmfej`jbobqf#jm#wkfnlmjwlqjmdqfslqwfgozwkf#nlgfqmtlqhjmd#lmbooltfg#wltkfqf#wkf#jmmlubwjuf?,b=?,gju=plvmgwqb`hpfbq`kElqnwfmg#wl#afjmsvw#jg>!lsfmjmd#leqfpwqj`wfgbglswfg#azbggqfppjmdwkfloldjbmnfwklgp#leubqjbmw#le@kqjpwjbm#ufqz#obqdfbvwlnlwjufaz#ebq#wkfqbmdf#eqlnsvqpvjw#leeloolt#wkfaqlvdkw#wljm#Fmdobmgbdqff#wkbwb``vpfg#le`lnfp#eqlnsqfufmwjmdgju#pwzof>kjp#lq#kfqwqfnfmglvpeqffgln#le`lm`fqmjmd3#2fn#2fn8Abphfwaboo,pwzof-`ppbm#fbqojfqfufm#bewfq,!#wjwof>!-`ln,jmgf{wbhjmd#wkfsjwwpavqdk`lmwfmw!=\u000E?p`qjsw=+ewvqmfg#lvwkbujmd#wkf?,psbm=\u000E\t#l``bpjlmboaf`bvpf#jwpwbqwfg#wlskzpj`booz=?,gju=\t##`qfbwfg#az@vqqfmwoz/#ad`lolq>!wbajmgf{>!gjpbpwqlvpBmbozwj`p#bopl#kbp#b=?gju#jg>!?,pwzof=\t?`boofg#elqpjmdfq#bmg-pq`#>#!,,ujlobwjlmpwkjp#sljmw`lmpwbmwozjp#ol`bwfgqf`lqgjmdpg#eqln#wkfmfgfqobmgpslqwvdv/Fp;N;};D;u;F5m4K4]4_7`gfpbqqlool`lnfmwbqjlfgv`b`j/_mpfswjfnaqfqfdjpwqbglgjqf``j/_mvaj`b`j/_msvaoj`jgbgqfpsvfpwbpqfpvowbglpjnslqwbmwfqfpfqubglpbqw/A`volpgjefqfmwfppjdvjfmwfpqfs/Vaoj`bpjwvb`j/_mnjmjpwfqjlsqjub`jgbggjqf`wlqjlelqnb`j/_mslaob`j/_msqfpjgfmwf`lmw"; + private static final String DATA1 = "fmjglpb``fplqjlpwf`kmlqbwjsfqplmbofp`bwfdlq/Abfpsf`jbofpgjpslmjaofb`wvbojgbgqfefqfm`jbuboobglojgajaojlwf`bqfob`jlmfp`bofmgbqjlslo/Awj`bpbmwfqjlqfpgl`vnfmwlpmbwvqbofybnbwfqjbofpgjefqfm`jbf`lm/_nj`bwqbmpslqwfqlgq/Advfysbqwj`jsbqfm`vfmwqbmgjp`vpj/_mfpwqv`wvqbevmgb`j/_meqf`vfmwfpsfqnbmfmwfwlwbonfmwf!2s{#plojg# -dje!#bow>!wqbmpsbqfmwjmelqnbwjlmbssoj`bwjlm!#lm`oj`h>!fpwbaojpkfgbgufqwjpjmd-smd!#bow>!fmujqlmnfmwsfqelqnbm`fbssqlsqjbwf%bns8ngbpk8jnnfgjbwfoz?,pwqlmd=?,qbwkfq#wkbmwfnsfqbwvqfgfufolsnfmw`lnsfwjwjlmsob`fklogfqujpjajojwz9`lszqjdkw!=3!#kfjdkw>!fufm#wklvdkqfsob`fnfmwgfpwjmbwjlm@lqslqbwjlm?vo#`obpp>!Bppl`jbwjlmjmgjujgvbopsfqpsf`wjufpfwWjnflvw+vqo+kwws9,,nbwkfnbwj`pnbqdjm.wls9fufmwvbooz#gfp`qjswjlm*#ml.qfsfbw`loof`wjlmp-ISD\u007Fwkvna\u007Fsbqwj`jsbwf,kfbg=?algzeolbw9ofew8?oj#`obpp>!kvmgqfgp#le\t\tKltfufq/#`lnslpjwjlm`ofbq9alwk8`llsfqbwjlmtjwkjm#wkf#obafo#elq>!alqgfq.wls9Mft#Yfbobmgqf`lnnfmgfgsklwldqbskzjmwfqfpwjmd%ow8pvs%dw8`lmwqlufqpzMfwkfqobmgpbowfqmbwjufnb{ofmdwk>!ptjwyfqobmgGfufolsnfmwfppfmwjbooz\t\tBowklvdk#?,wf{wbqfb=wkvmgfqajqgqfsqfpfmwfg%bns8mgbpk8psf`vobwjlm`lnnvmjwjfpofdjpobwjlmfof`wqlmj`p\t\n?gju#jg>!joovpwqbwfgfmdjmffqjmdwfqqjwlqjfpbvwklqjwjfpgjpwqjavwfg5!#kfjdkw>!pbmp.pfqje8`bsbaof#le#gjpbssfbqfgjmwfqb`wjufollhjmd#elqjw#tlvog#afBedkbmjpwbmtbp#`qfbwfgNbwk-eollq+pvqqlvmgjmd`bm#bopl#aflapfqubwjlmnbjmwfmbm`ffm`lvmwfqfg?k1#`obpp>!nlqf#qf`fmwjw#kbp#affmjmubpjlm#le*-dfwWjnf+*evmgbnfmwboGfpsjwf#wkf!=?gju#jg>!jmpsjqbwjlmf{bnjmbwjlmsqfsbqbwjlmf{sobmbwjlm?jmsvw#jg>!?,b=?,psbm=ufqpjlmp#lejmpwqvnfmwpafelqf#wkf##>#$kwws9,,Gfp`qjswjlmqfobwjufoz#-pvapwqjmd+fb`k#le#wkff{sfqjnfmwpjmeovfmwjbojmwfdqbwjlmnbmz#sflsofgvf#wl#wkf#`lnajmbwjlmgl#mlw#kbufNjggof#Fbpw?mlp`qjsw=?`lszqjdkw!#sfqkbsp#wkfjmpwjwvwjlmjm#Gf`fnafqbqqbmdfnfmwnlpw#ebnlvpsfqplmbojwz`qfbwjlm#leojnjwbwjlmpf{`ovpjufozplufqfjdmwz.`lmwfmw!=\t?wg#`obpp>!vmgfqdqlvmgsbqboofo#wlgl`wqjmf#lel``vsjfg#azwfqnjmloldzQfmbjppbm`fb#mvnafq#lepvsslqw#elqf{solqbwjlmqf`ldmjwjlmsqfgf`fpplq?jnd#pq`>!,?k2#`obpp>!svaoj`bwjlmnbz#bopl#afpsf`jbojyfg?,ejfogpfw=sqldqfppjufnjoojlmp#lepwbwfp#wkbwfmelq`fnfmwbqlvmg#wkf#lmf#bmlwkfq-sbqfmwMlgfbdqj`vowvqfBowfqmbwjufqfpfbq`kfqpwltbqgp#wkfNlpw#le#wkfnbmz#lwkfq#+fpsf`jbooz?wg#tjgwk>!8tjgwk9233&jmgfsfmgfmw?k0#`obpp>!#lm`kbmdf>!*-bgg@obpp+jmwfqb`wjlmLmf#le#wkf#gbvdkwfq#leb``fpplqjfpaqbm`kfp#le\u000E\t?gju#jg>!wkf#obqdfpwgf`obqbwjlmqfdvobwjlmpJmelqnbwjlmwqbmpobwjlmgl`vnfmwbqzjm#lqgfq#wl!=\t?kfbg=\t?!#kfjdkw>!2b`qlpp#wkf#lqjfmwbwjlm*8?,p`qjsw=jnsofnfmwfg`bm#af#pffmwkfqf#tbp#bgfnlmpwqbwf`lmwbjmfq!=`lmmf`wjlmpwkf#Aqjwjpktbp#tqjwwfm\"jnslqwbmw8s{8#nbqdjm.elooltfg#azbajojwz#wl#`lnsoj`bwfggvqjmd#wkf#jnnjdqbwjlmbopl#`boofg?k7#`obpp>!gjpwjm`wjlmqfsob`fg#azdlufqmnfmwpol`bwjlm#lejm#Mlufnafqtkfwkfq#wkf?,s=\t?,gju=b`rvjpjwjlm`boofg#wkf#sfqpf`vwjlmgfpjdmbwjlmxelmw.pjyf9bssfbqfg#jmjmufpwjdbwff{sfqjfm`fgnlpw#ojhfoztjgfoz#vpfggjp`vppjlmpsqfpfm`f#le#+gl`vnfmw-f{wfmpjufozJw#kbp#affmjw#glfp#mlw`lmwqbqz#wljmkbajwbmwpjnsqlufnfmwp`klobqpkjs`lmpvnswjlmjmpwqv`wjlmelq#f{bnsoflmf#lq#nlqfs{8#sbggjmdwkf#`vqqfmwb#pfqjfp#lebqf#vpvboozqlof#jm#wkfsqfujlvpoz#gfqjubwjufpfujgfm`f#lef{sfqjfm`fp`lolqp`kfnfpwbwfg#wkbw`fqwjej`bwf?,b=?,gju=\t#pfof`wfg>!kjdk#p`klloqfpslmpf#wl`lnelqwbaofbglswjlm#lewkqff#zfbqpwkf#`lvmwqzjm#Efaqvbqzpl#wkbw#wkfsflsof#tkl#sqlujgfg#az?sbqbn#mbnfbeef`wfg#azjm#wfqnp#lebssljmwnfmwJPL.;;6:.2!tbp#alqm#jmkjpwlqj`bo#qfdbqgfg#bpnfbpvqfnfmwjp#abpfg#lm#bmg#lwkfq#9#evm`wjlm+pjdmjej`bmw`fofaqbwjlmwqbmpnjwwfg,ip,irvfqz-jp#hmltm#bpwkflqfwj`bo#wbajmgf{>!jw#`lvog#af?mlp`qjsw=\tkbujmd#affm\u000E\t?kfbg=\u000E\t?#%rvlw8Wkf#`lnsjobwjlmkf#kbg#affmsqlgv`fg#azskjolplskfq`lmpwqv`wfgjmwfmgfg#wlbnlmd#lwkfq`lnsbqfg#wlwl#pbz#wkbwFmdjmffqjmdb#gjeefqfmwqfefqqfg#wlgjeefqfm`fpafojfe#wkbwsklwldqbskpjgfmwjezjmdKjpwlqz#le#Qfsvaoj`#lemf`fppbqjozsqlabajojwzwf`kmj`boozofbujmd#wkfpsf`wb`vobqeqb`wjlm#lefof`wqj`jwzkfbg#le#wkfqfpwbvqbmwpsbqwmfqpkjsfnskbpjp#lmnlpw#qf`fmwpkbqf#tjwk#pbzjmd#wkbwejoofg#tjwkgfpjdmfg#wljw#jp#lewfm!=?,jeqbnf=bp#elooltp9nfqdfg#tjwkwkqlvdk#wkf`lnnfq`jbo#sljmwfg#lvwlsslqwvmjwzujft#le#wkfqfrvjqfnfmwgjujpjlm#lesqldqbnnjmdkf#qf`fjufgpfwJmwfqubo!=?,psbm=?,jm#Mft#Zlqhbggjwjlmbo#`lnsqfppjlm\t\t?gju#jg>!jm`lqslqbwf8?,p`qjsw=?bwwb`kFufmwaf`bnf#wkf#!#wbqdfw>!\\`bqqjfg#lvwPlnf#le#wkfp`jfm`f#bmgwkf#wjnf#le@lmwbjmfq!=nbjmwbjmjmd@kqjpwlskfqNv`k#le#wkftqjwjmdp#le!#kfjdkw>!1pjyf#le#wkfufqpjlm#le#nj{wvqf#le#afwtffm#wkfF{bnsofp#lefgv`bwjlmbo`lnsfwjwjuf#lmpvanjw>!gjqf`wlq#legjpwjm`wjuf,GWG#[KWNO#qfobwjmd#wlwfmgfm`z#wlsqlujm`f#letkj`k#tlvoggfpsjwf#wkfp`jfmwjej`#ofdjpobwvqf-jmmfqKWNO#boofdbwjlmpBdqj`vowvqftbp#vpfg#jmbssqlb`k#wljmwfoojdfmwzfbqp#obwfq/pbmp.pfqjegfwfqnjmjmdSfqelqnbm`fbssfbqbm`fp/#tkj`k#jp#elvmgbwjlmpbaaqfujbwfgkjdkfq#wkbmp#eqln#wkf#jmgjujgvbo#`lnslpfg#lepvsslpfg#wl`objnp#wkbwbwwqjavwjlmelmw.pjyf92fofnfmwp#leKjpwlqj`bo#kjp#aqlwkfqbw#wkf#wjnfbmmjufqpbqzdlufqmfg#azqfobwfg#wl#vowjnbwfoz#jmmlubwjlmpjw#jp#pwjoo`bm#lmoz#afgfejmjwjlmpwlDNWPwqjmdB#mvnafq#lejnd#`obpp>!Fufmwvbooz/tbp#`kbmdfgl``vqqfg#jmmfjdkalqjmdgjpwjmdvjpktkfm#kf#tbpjmwqlgv`jmdwfqqfpwqjboNbmz#le#wkfbqdvfp#wkbwbm#Bnfqj`bm`lmrvfpw#letjgfpsqfbg#tfqf#hjoofgp`qffm#bmg#Jm#lqgfq#wlf{sf`wfg#wlgfp`fmgbmwpbqf#ol`bwfgofdjpobwjufdfmfqbwjlmp#ab`hdqlvmgnlpw#sflsofzfbqp#bewfqwkfqf#jp#mlwkf#kjdkfpweqfrvfmwoz#wkfz#gl#mlwbqdvfg#wkbwpkltfg#wkbwsqfglnjmbmwwkfloldj`boaz#wkf#wjnf`lmpjgfqjmdpklqw.ojufg?,psbm=?,b=`bm#af#vpfgufqz#ojwwoflmf#le#wkf#kbg#boqfbgzjmwfqsqfwfg`lnnvmj`bwfefbwvqfp#ledlufqmnfmw/?,mlp`qjsw=fmwfqfg#wkf!#kfjdkw>!0Jmgfsfmgfmwslsvobwjlmpobqdf.p`bof-#Bowklvdk#vpfg#jm#wkfgfpwqv`wjlmslppjajojwzpwbqwjmd#jmwtl#lq#nlqff{sqfppjlmppvalqgjmbwfobqdfq#wkbmkjpwlqz#bmg?,lswjlm=\u000E\t@lmwjmfmwbofojnjmbwjmdtjoo#mlw#afsqb`wj`f#lejm#eqlmw#lepjwf#le#wkffmpvqf#wkbwwl#`qfbwf#bnjppjppjssjslwfmwjboozlvwpwbmgjmdafwwfq#wkbmtkbw#jp#mltpjwvbwfg#jmnfwb#mbnf>!WqbgjwjlmbopvddfpwjlmpWqbmpobwjlmwkf#elqn#lebwnlpskfqj`jgfloldj`bofmwfqsqjpfp`bo`vobwjmdfbpw#le#wkfqfnmbmwp#lesovdjmpsbdf,jmgf{-sks!Wkjp#jp#wkf#?b#kqfe>!,slsvobqjyfgjmuloufg#jmbqf#vpfg#wlbmg#pfufqbonbgf#az#wkfpffnp#wl#afojhfoz#wkbwSbofpwjmjbmmbnfg#bewfqjw#kbg#affmnlpw#`lnnlmwl#qfefq#wlavw#wkjp#jp`lmpf`vwjufwfnslqbqjozJm#dfmfqbo/`lmufmwjlmpwbhfp#sob`fpvagjujpjlmwfqqjwlqjbolsfqbwjlmbosfqnbmfmwoztbp#obqdfozlvwaqfbh#lejm#wkf#sbpwelooltjmd#b#{nomp9ld>!=?b#`obpp>!`obpp>!wf{w@lmufqpjlm#nbz#af#vpfgnbmveb`wvqfbewfq#afjmd`ofbqej{!=\trvfpwjlm#letbp#fof`wfgwl#af`lnf#baf`bvpf#le#plnf#sflsofjmpsjqfg#azpv``fppevo#b#wjnf#tkfmnlqf#`lnnlmbnlmdpw#wkfbm#leej`jbotjgwk9233&8wf`kmloldz/tbp#bglswfgwl#hffs#wkfpfwwofnfmwpojuf#ajqwkpjmgf{-kwno!@lmmf`wj`vwbppjdmfg#wl%bns8wjnfp8b``lvmw#elqbojdm>qjdkwwkf#`lnsbmzbotbzp#affmqfwvqmfg#wljmuloufnfmwAf`bvpf#wkfwkjp#sfqjlg!#mbnf>!r!#`lmejmfg#wlb#qfpvow#leubovf>!!#,=jp#b`wvboozFmujqlmnfmw\u000E\t?,kfbg=\u000E\t@lmufqpfoz/=\t?gju#jg>!3!#tjgwk>!2jp#sqlabaozkbuf#af`lnf`lmwqloojmdwkf#sqlaofn`jwjyfmp#leslojwj`jbmpqfb`kfg#wkfbp#fbqoz#bp9mlmf8#lufq?wbaof#`fooubojgjwz#legjqf`woz#wllmnlvpfgltmtkfqf#jw#jptkfm#jw#tbpnfnafqp#le#qfobwjlm#wlb``lnnlgbwfbolmd#tjwk#Jm#wkf#obwfwkf#Fmdojpkgfoj`jlvp!=wkjp#jp#mlwwkf#sqfpfmwje#wkfz#bqfbmg#ejmboozb#nbwwfq#le\u000E\t\n?,gju=\u000E\t\u000E\t?,p`qjsw=ebpwfq#wkbmnbilqjwz#lebewfq#tkj`k`lnsbqbwjufwl#nbjmwbjmjnsqluf#wkfbtbqgfg#wkffq!#`obpp>!eqbnfalqgfqqfpwlqbwjlmjm#wkf#pbnfbmbozpjp#lewkfjq#ejqpwGvqjmd#wkf#`lmwjmfmwbopfrvfm`f#leevm`wjlm+*xelmw.pjyf9#tlqh#lm#wkf?,p`qjsw=\t?afdjmp#tjwkibubp`qjsw9`lmpwjwvfmwtbp#elvmgfgfrvjojaqjvnbppvnf#wkbwjp#djufm#azmffgp#wl#af`llqgjmbwfpwkf#ubqjlvpbqf#sbqw#lelmoz#jm#wkfpf`wjlmp#lejp#b#`lnnlmwkflqjfp#legjp`lufqjfpbppl`jbwjlmfgdf#le#wkfpwqfmdwk#leslpjwjlm#jmsqfpfmw.gbzvmjufqpboozwl#elqn#wkfavw#jmpwfbg`lqslqbwjlmbwwb`kfg#wljp#`lnnlmozqfbplmp#elq#%rvlw8wkf#`bm#af#nbgftbp#baof#wltkj`k#nfbmpavw#gjg#mlwlmNlvpfLufqbp#slppjaoflsfqbwfg#az`lnjmd#eqlnwkf#sqjnbqzbggjwjlm#leelq#pfufqbowqbmpefqqfgb#sfqjlg#lebqf#baof#wlkltfufq/#jwpklvog#kbufnv`k#obqdfq\t\n?,p`qjsw=bglswfg#wkfsqlsfqwz#legjqf`wfg#azfeef`wjufoztbp#aqlvdkw`kjogqfm#leSqldqbnnjmdolmdfq#wkbmnbmvp`qjswptbq#bdbjmpwaz#nfbmp#lebmg#nlpw#lepjnjobq#wl#sqlsqjfwbqzlqjdjmbwjmdsqfpwjdjlvpdqbnnbwj`bof{sfqjfm`f-wl#nbhf#wkfJw#tbp#bopljp#elvmg#jm`lnsfwjwlqpjm#wkf#V-P-qfsob`f#wkfaqlvdkw#wkf`bo`vobwjlmeboo#le#wkfwkf#dfmfqbosqb`wj`boozjm#klmlq#leqfofbpfg#jmqfpjgfmwjbobmg#plnf#lehjmd#le#wkfqfb`wjlm#wl2pw#Fbqo#le`vowvqf#bmgsqjm`jsbooz?,wjwof=\t##wkfz#`bm#afab`h#wl#wkfplnf#le#kjpf{slpvqf#wlbqf#pjnjobqelqn#le#wkfbggEbulqjwf`jwjyfmpkjssbqw#jm#wkfsflsof#tjwkjm#sqb`wj`fwl#`lmwjmvf%bns8njmvp8bssqlufg#az#wkf#ejqpw#booltfg#wkfbmg#elq#wkfevm`wjlmjmdsobzjmd#wkfplovwjlm#wlkfjdkw>!3!#jm#kjp#allhnlqf#wkbm#belooltp#wkf`qfbwfg#wkfsqfpfm`f#jm%maps8?,wg=mbwjlmbojpwwkf#jgfb#leb#`kbqb`wfqtfqf#elq`fg#`obpp>!awmgbzp#le#wkfefbwvqfg#jmpkltjmd#wkfjmwfqfpw#jmjm#sob`f#lewvqm#le#wkfwkf#kfbg#leOlqg#le#wkfslojwj`boozkbp#jwp#ltmFgv`bwjlmbobssqlubo#leplnf#le#wkffb`k#lwkfq/afkbujlq#lebmg#af`bvpfbmg#bmlwkfqbssfbqfg#lmqf`lqgfg#jmaob`h%rvlw8nbz#jm`ovgfwkf#tlqog$p`bm#ofbg#wlqfefqp#wl#balqgfq>!3!#dlufqmnfmw#tjmmjmd#wkfqfpvowfg#jm#tkjof#wkf#Tbpkjmdwlm/wkf#pvaif`w`jwz#jm#wkf=?,gju=\u000E\t\n\nqfeof`w#wkfwl#`lnsofwfaf`bnf#nlqfqbgjlb`wjufqfif`wfg#aztjwklvw#bmzkjp#ebwkfq/tkj`k#`lvog`lsz#le#wkfwl#jmgj`bwfb#slojwj`bob``lvmwp#le`lmpwjwvwfptlqhfg#tjwkfq?,b=?,oj=le#kjp#ojefb``lnsbmjfg`ojfmwTjgwksqfufmw#wkfOfdjpobwjufgjeefqfmwozwldfwkfq#jmkbp#pfufqboelq#bmlwkfqwf{w#le#wkfelvmgfg#wkff#tjwk#wkf#jp#vpfg#elq`kbmdfg#wkfvpvbooz#wkfsob`f#tkfqftkfqfbp#wkf=#?b#kqfe>!!=?b#kqfe>!wkfnpfoufp/bowklvdk#kfwkbw#`bm#afwqbgjwjlmboqlof#le#wkfbp#b#qfpvowqfnluf@kjoggfpjdmfg#aztfpw#le#wkfPlnf#sflsofsqlgv`wjlm/pjgf#le#wkfmftpofwwfqpvpfg#az#wkfgltm#wl#wkfb``fswfg#azojuf#jm#wkfbwwfnswp#wllvwpjgf#wkfeqfrvfm`jfpKltfufq/#jmsqldqbnnfqpbw#ofbpw#jmbssql{jnbwfbowklvdk#jwtbp#sbqw#lebmg#ubqjlvpDlufqmlq#lewkf#bqwj`ofwvqmfg#jmwl=?b#kqfe>!,wkf#f`lmlnzjp#wkf#nlpwnlpw#tjgfoztlvog#obwfqbmg#sfqkbspqjpf#wl#wkfl``vqp#tkfmvmgfq#tkj`k`lmgjwjlmp-wkf#tfpwfqmwkflqz#wkbwjp#sqlgv`fgwkf#`jwz#lejm#tkj`k#kfpffm#jm#wkfwkf#`fmwqboavjogjmd#lenbmz#le#kjpbqfb#le#wkfjp#wkf#lmoznlpw#le#wkfnbmz#le#wkfwkf#TfpwfqmWkfqf#jp#mlf{wfmgfg#wlPwbwjpwj`bo`lopsbm>1#\u007Fpklqw#pwlqzslppjaof#wlwlsloldj`bo`qjwj`bo#leqfslqwfg#wlb#@kqjpwjbmgf`jpjlm#wljp#frvbo#wlsqlaofnp#leWkjp#`bm#afnfq`kbmgjpfelq#nlpw#leml#fujgfm`ffgjwjlmp#lefofnfmwp#jm%rvlw8-#Wkf`ln,jnbdfp,tkj`k#nbhfpwkf#sql`fppqfnbjmp#wkfojwfqbwvqf/jp#b#nfnafqwkf#slsvobqwkf#bm`jfmwsqlaofnp#jmwjnf#le#wkfgfefbwfg#azalgz#le#wkfb#eft#zfbqpnv`k#le#wkfwkf#tlqh#le@bojelqmjb/pfqufg#bp#bdlufqmnfmw-`lm`fswp#lenlufnfmw#jm\n\n?gju#jg>!jw!#ubovf>!obmdvbdf#lebp#wkfz#bqfsqlgv`fg#jmjp#wkbw#wkff{sobjm#wkfgju=?,gju=\tKltfufq#wkfofbg#wl#wkf\n?b#kqfe>!,tbp#dqbmwfgsflsof#kbuf`lmwjmvbooztbp#pffm#bpbmg#qfobwfgwkf#qlof#lesqlslpfg#azle#wkf#afpwfb`k#lwkfq-@lmpwbmwjmfsflsof#eqlngjbof`wp#lewl#qfujpjlmtbp#qfmbnfgb#plvq`f#lewkf#jmjwjboobvm`kfg#jmsqlujgf#wkfwl#wkf#tfpwtkfqf#wkfqfbmg#pjnjobqafwtffm#wtljp#bopl#wkfFmdojpk#bmg`lmgjwjlmp/wkbw#jw#tbpfmwjwofg#wlwkfnpfoufp-rvbmwjwz#leqbmpsbqfm`zwkf#pbnf#bpwl#iljm#wkf`lvmwqz#bmgwkjp#jp#wkfWkjp#ofg#wlb#pwbwfnfmw`lmwqbpw#wlobpwJmgf{Lewkqlvdk#kjpjp#gfpjdmfgwkf#wfqn#jpjp#sqlujgfgsqlwf`w#wkfmd?,b=?,oj=Wkf#`vqqfmwwkf#pjwf#lepvapwbmwjbof{sfqjfm`f/jm#wkf#Tfpwwkfz#pklvogpolufm(ajmb`lnfmwbqjlpvmjufqpjgbg`lmgj`jlmfpb`wjujgbgfpf{sfqjfm`jbwf`mlold/Absqlgv``j/_msvmwvb`j/_mbsoj`b`j/_m`lmwqbpf/]b`bwfdlq/Abpqfdjpwqbqpfsqlefpjlmbowqbwbnjfmwlqfd/Apwqbwfpf`qfwbq/Absqjm`jsbofpsqlwf``j/_mjnslqwbmwfpjnslqwbm`jbslpjajojgbgjmwfqfpbmwf`qf`jnjfmwlmf`fpjgbgfppvp`qjajqpfbpl`jb`j/_mgjpslmjaofpfubovb`j/_mfpwvgjbmwfpqfpslmpbaofqfplov`j/_mdvbgbobibqbqfdjpwqbglplslqwvmjgbg`lnfq`jbofpelwldqbe/Abbvwlqjgbgfpjmdfmjfq/Abwfofujpj/_m`lnsfwfm`jblsfqb`jlmfpfpwbaof`jglpjnsofnfmwfb`wvbonfmwfmbufdb`j/_m`lmelqnjgbgojmf.kfjdkw9elmw.ebnjoz9!#9#!kwws9,,bssoj`bwjlmpojmh!#kqfe>!psf`jej`booz,,?\"X@GBWBX\tLqdbmjybwjlmgjpwqjavwjlm3s{8#kfjdkw9qfobwjlmpkjsgfuj`f.tjgwk?gju#`obpp>!?obafo#elq>!qfdjpwqbwjlm?,mlp`qjsw=\t,jmgf{-kwno!tjmglt-lsfm+#\"jnslqwbmw8bssoj`bwjlm,jmgfsfmgfm`f,,ttt-dlldoflqdbmjybwjlmbvwl`lnsofwfqfrvjqfnfmwp`lmpfqubwjuf?elqn#mbnf>!jmwfoof`wvbonbqdjm.ofew92;wk#`fmwvqzbm#jnslqwbmwjmpwjwvwjlmpbaaqfujbwjlm?jnd#`obpp>!lqdbmjpbwjlm`jujojybwjlm2:wk#`fmwvqzbq`kjwf`wvqfjm`lqslqbwfg13wk#`fmwvqz.`lmwbjmfq!=nlpw#mlwbaoz,=?,b=?,gju=mlwjej`bwjlm$vmgfejmfg$*Evqwkfqnlqf/afojfuf#wkbwjmmfqKWNO#>#sqjlq#wl#wkfgqbnbwj`boozqfefqqjmd#wlmfdlwjbwjlmpkfbgrvbqwfqpPlvwk#Beqj`bvmpv``fppevoSfmmpzoubmjbBp#b#qfpvow/?kwno#obmd>!%ow8,pvs%dw8gfbojmd#tjwkskjobgfoskjbkjpwlqj`booz*8?,p`qjsw=\tsbggjmd.wls9f{sfqjnfmwbodfwBwwqjavwfjmpwqv`wjlmpwf`kmloldjfpsbqw#le#wkf#>evm`wjlm+*xpvap`qjswjlmo-gwg!=\u000E\t?kwdfldqbskj`bo@lmpwjwvwjlm$/#evm`wjlm+pvsslqwfg#azbdqj`vowvqbo`lmpwqv`wjlmsvaoj`bwjlmpelmw.pjyf9#2b#ubqjfwz#le?gju#pwzof>!Fm`z`olsfgjbjeqbnf#pq`>!gfnlmpwqbwfgb``lnsojpkfgvmjufqpjwjfpGfnldqbskj`p*8?,p`qjsw=?gfgj`bwfg#wlhmltofgdf#lepbwjpeb`wjlmsbqwj`vobqoz?,gju=?,gju=Fmdojpk#+VP*bssfmg@kjog+wqbmpnjppjlmp-#Kltfufq/#jmwfoojdfm`f!#wbajmgf{>!eolbw9qjdkw8@lnnlmtfbowkqbmdjmd#eqlnjm#tkj`k#wkfbw#ofbpw#lmfqfsqlgv`wjlmfm`z`olsfgjb8elmw.pjyf92ivqjpgj`wjlmbw#wkbw#wjnf!=?b#`obpp>!Jm#bggjwjlm/gfp`qjswjlm(`lmufqpbwjlm`lmwb`w#tjwkjp#dfmfqboozq!#`lmwfmw>!qfsqfpfmwjmd%ow8nbwk%dw8sqfpfmwbwjlml``bpjlmbooz?jnd#tjgwk>!mbujdbwjlm!=`lnsfmpbwjlm`kbnsjlmpkjsnfgjb>!boo!#ujlobwjlm#leqfefqfm`f#wlqfwvqm#wqvf8Pwqj`w,,FM!#wqbmpb`wjlmpjmwfqufmwjlmufqjej`bwjlmJmelqnbwjlm#gjeej`vowjfp@kbnsjlmpkjs`bsbajojwjfp?\"Xfmgje^..=~\t?,p`qjsw=\t@kqjpwjbmjwzelq#f{bnsof/Sqlefppjlmboqfpwqj`wjlmppvddfpw#wkbwtbp#qfofbpfg+pv`k#bp#wkfqfnluf@obpp+vmfnsolznfmwwkf#Bnfqj`bmpwqv`wvqf#le,jmgf{-kwno#svaojpkfg#jmpsbm#`obpp>!!=?b#kqfe>!,jmwqlgv`wjlmafolmdjmd#wl`objnfg#wkbw`lmpfrvfm`fp?nfwb#mbnf>!Dvjgf#wl#wkflufqtkfonjmdbdbjmpw#wkf#`lm`fmwqbwfg/\t-mlmwlv`k#lapfqubwjlmp?,b=\t?,gju=\te#+gl`vnfmw-alqgfq9#2s{#xelmw.pjyf92wqfbwnfmw#le3!#kfjdkw>!2nlgjej`bwjlmJmgfsfmgfm`fgjujgfg#jmwldqfbwfq#wkbmb`kjfufnfmwpfpwbaojpkjmdIbubP`qjsw!#mfufqwkfofpppjdmjej`bm`fAqlbg`bpwjmd=%maps8?,wg=`lmwbjmfq!=\tpv`k#bp#wkf#jmeovfm`f#leb#sbqwj`vobqpq`>$kwws9,,mbujdbwjlm!#kboe#le#wkf#pvapwbmwjbo#%maps8?,gju=bgubmwbdf#legjp`lufqz#leevmgbnfmwbo#nfwqlslojwbmwkf#lsslpjwf!#{no9obmd>!gfojafqbwfozbojdm>`fmwfqfulovwjlm#lesqfpfqubwjlmjnsqlufnfmwpafdjmmjmd#jmIfpvp#@kqjpwSvaoj`bwjlmpgjpbdqffnfmwwf{w.bojdm9q/#evm`wjlm+*pjnjobqjwjfpalgz=?,kwno=jp#`vqqfmwozboskbafwj`bojp#plnfwjnfpwzsf>!jnbdf,nbmz#le#wkf#eolt9kjggfm8bubjobaof#jmgfp`qjaf#wkff{jpwfm`f#leboo#lufq#wkfwkf#Jmwfqmfw\n?vo#`obpp>!jmpwboobwjlmmfjdkalqkllgbqnfg#elq`fpqfgv`jmd#wkf`lmwjmvfp#wlMlmfwkfofpp/wfnsfqbwvqfp\t\n\n?b#kqfe>!`olpf#wl#wkff{bnsofp#le#jp#balvw#wkf+pff#afolt*-!#jg>!pfbq`ksqlefppjlmbojp#bubjobaofwkf#leej`jbo\n\n?,p`qjsw=\t\t\n\n?gju#jg>!b``fofqbwjlmwkqlvdk#wkf#Kboo#le#Ebnfgfp`qjswjlmpwqbmpobwjlmpjmwfqefqfm`f#wzsf>$wf{w,qf`fmw#zfbqpjm#wkf#tlqogufqz#slsvobqxab`hdqlvmg9wqbgjwjlmbo#plnf#le#wkf#`lmmf`wfg#wlf{soljwbwjlmfnfqdfm`f#le`lmpwjwvwjlmB#Kjpwlqz#lepjdmjej`bmw#nbmveb`wvqfgf{sf`wbwjlmp=?mlp`qjsw=?`bm#af#elvmgaf`bvpf#wkf#kbp#mlw#affmmfjdkalvqjmdtjwklvw#wkf#bggfg#wl#wkf\n?oj#`obpp>!jmpwqvnfmwboPlujfw#Vmjlmb`hmltofgdfgtkj`k#`bm#afmbnf#elq#wkfbwwfmwjlm#wlbwwfnswp#wl#gfufolsnfmwpJm#eb`w/#wkf?oj#`obpp>!bjnsoj`bwjlmppvjwbaof#elqnv`k#le#wkf#`lolmjybwjlmsqfpjgfmwjbo`bm`foAvaaof#Jmelqnbwjlmnlpw#le#wkf#jp#gfp`qjafgqfpw#le#wkf#nlqf#lq#ofppjm#PfswfnafqJmwfoojdfm`fpq`>!kwws9,,s{8#kfjdkw9#bubjobaof#wlnbmveb`wvqfqkvnbm#qjdkwpojmh#kqfe>!,bubjobajojwzsqlslqwjlmbolvwpjgf#wkf#bpwqlmlnj`bokvnbm#afjmdpmbnf#le#wkf#bqf#elvmg#jmbqf#abpfg#lmpnboofq#wkbmb#sfqplm#tklf{sbmpjlm#lebqdvjmd#wkbwmlt#hmltm#bpJm#wkf#fbqozjmwfqnfgjbwfgfqjufg#eqlnP`bmgjmbujbm?,b=?,gju=\u000E\t`lmpjgfq#wkfbm#fpwjnbwfgwkf#Mbwjlmbo?gju#jg>!sbdqfpvowjmd#jm`lnnjppjlmfgbmboldlvp#wlbqf#qfrvjqfg,vo=\t?,gju=\ttbp#abpfg#lmbmg#af`bnf#b%maps8%maps8w!#ubovf>!!#tbp#`bswvqfgml#nlqf#wkbmqfpsf`wjufoz`lmwjmvf#wl#=\u000E\t?kfbg=\u000E\t?tfqf#`qfbwfgnlqf#dfmfqbojmelqnbwjlm#vpfg#elq#wkfjmgfsfmgfmw#wkf#Jnsfqjbo`lnslmfmw#lewl#wkf#mlqwkjm`ovgf#wkf#@lmpwqv`wjlmpjgf#le#wkf#tlvog#mlw#afelq#jmpwbm`fjmufmwjlm#lenlqf#`lnsof{`loof`wjufozab`hdqlvmg9#wf{w.bojdm9#jwp#lqjdjmbojmwl#b``lvmwwkjp#sql`fppbm#f{wfmpjufkltfufq/#wkfwkfz#bqf#mlwqfif`wfg#wkf`qjwj`jpn#legvqjmd#tkj`ksqlabaoz#wkfwkjp#bqwj`of+evm`wjlm+*xJw#pklvog#afbm#bdqffnfmwb``jgfmwboozgjeefqp#eqlnBq`kjwf`wvqfafwwfq#hmltmbqqbmdfnfmwpjmeovfm`f#lmbwwfmgfg#wkfjgfmwj`bo#wlplvwk#le#wkfsbpp#wkqlvdk{no!#wjwof>!tfjdkw9alog8`qfbwjmd#wkfgjpsobz9mlmfqfsob`fg#wkf?jnd#pq`>!,jkwwsp9,,ttt-Tlqog#Tbq#JJwfpwjnlmjbopelvmg#jm#wkfqfrvjqfg#wl#bmg#wkbw#wkfafwtffm#wkf#tbp#gfpjdmfg`lmpjpwp#le#`lmpjgfqbaozsvaojpkfg#azwkf#obmdvbdf@lmpfqubwjlm`lmpjpwfg#leqfefq#wl#wkfab`h#wl#wkf#`pp!#nfgjb>!Sflsof#eqln#bubjobaof#lmsqlufg#wl#afpvddfpwjlmp!tbp#hmltm#bpubqjfwjfp#leojhfoz#wl#af`lnsqjpfg#lepvsslqw#wkf#kbmgp#le#wkf`lvsofg#tjwk`lmmf`w#bmg#alqgfq9mlmf8sfqelqnbm`fpafelqf#afjmdobwfq#af`bnf`bo`vobwjlmplewfm#`boofgqfpjgfmwp#lenfbmjmd#wkbw=?oj#`obpp>!fujgfm`f#elqf{sobmbwjlmpfmujqlmnfmwp!=?,b=?,gju=tkj`k#booltpJmwqlgv`wjlmgfufolsfg#azb#tjgf#qbmdflm#afkboe#leubojdm>!wls!sqjm`jsof#lebw#wkf#wjnf/?,mlp`qjsw=\u000Epbjg#wl#kbufjm#wkf#ejqpwtkjof#lwkfqpkzslwkfwj`boskjolplskfqpsltfq#le#wkf`lmwbjmfg#jmsfqelqnfg#azjmbajojwz#wltfqf#tqjwwfmpsbm#pwzof>!jmsvw#mbnf>!wkf#rvfpwjlmjmwfmgfg#elqqfif`wjlm#lejnsojfp#wkbwjmufmwfg#wkfwkf#pwbmgbqgtbp#sqlabaozojmh#afwtffmsqlefpplq#lejmwfqb`wjlmp`kbmdjmd#wkfJmgjbm#L`fbm#`obpp>!obpwtlqhjmd#tjwk$kwws9,,ttt-zfbqp#afelqfWkjp#tbp#wkfqf`qfbwjlmbofmwfqjmd#wkfnfbpvqfnfmwpbm#f{wqfnfozubovf#le#wkfpwbqw#le#wkf\t?,p`qjsw=\t\tbm#feelqw#wljm`qfbpf#wkfwl#wkf#plvwkpsb`jmd>!3!=pveej`jfmwozwkf#Fvqlsfbm`lmufqwfg#wl`ofbqWjnflvwgjg#mlw#kbuf`lmpfrvfmwozelq#wkf#mf{wf{wfmpjlm#lef`lmlnj`#bmgbowklvdk#wkfbqf#sqlgv`fgbmg#tjwk#wkfjmpveej`jfmwdjufm#az#wkfpwbwjmd#wkbwf{sfmgjwvqfp?,psbm=?,b=\twklvdkw#wkbwlm#wkf#abpjp`foosbggjmd>jnbdf#le#wkfqfwvqmjmd#wljmelqnbwjlm/pfsbqbwfg#azbppbppjmbwfgp!#`lmwfmw>!bvwklqjwz#lemlqwktfpwfqm?,gju=\t?gju#!=?,gju=\u000E\t##`lmpvowbwjlm`lnnvmjwz#lewkf#mbwjlmbojw#pklvog#afsbqwj`jsbmwp#bojdm>!ofewwkf#dqfbwfpwpfof`wjlm#lepvsfqmbwvqbogfsfmgfmw#lmjp#nfmwjlmfgbooltjmd#wkftbp#jmufmwfgb``lnsbmzjmdkjp#sfqplmbobubjobaof#bwpwvgz#le#wkflm#wkf#lwkfqf{f`vwjlm#leKvnbm#Qjdkwpwfqnp#le#wkfbppl`jbwjlmpqfpfbq`k#bmgpv``ffgfg#azgfefbwfg#wkfbmg#eqln#wkfavw#wkfz#bqf`lnnbmgfq#lepwbwf#le#wkfzfbqp#le#bdfwkf#pwvgz#le?vo#`obpp>!psob`f#jm#wkftkfqf#kf#tbp?oj#`obpp>!ewkfqf#bqf#mltkj`k#af`bnfkf#svaojpkfgf{sqfppfg#jmwl#tkj`k#wkf`lnnjppjlmfqelmw.tfjdkw9wfqqjwlqz#lef{wfmpjlmp!=Qlnbm#Fnsjqffrvbo#wl#wkfJm#`lmwqbpw/kltfufq/#bmgjp#wzsj`boozbmg#kjp#tjef+bopl#`boofg=?vo#`obpp>!feef`wjufoz#fuloufg#jmwlpffn#wl#kbuftkj`k#jp#wkfwkfqf#tbp#mlbm#f{`foofmwboo#le#wkfpfgfp`qjafg#azJm#sqb`wj`f/aqlbg`bpwjmd`kbqdfg#tjwkqfeof`wfg#jmpvaif`wfg#wlnjojwbqz#bmgwl#wkf#sljmwf`lmlnj`boozpfwWbqdfwjmdbqf#b`wvboozuj`wlqz#lufq+*8?,p`qjsw=`lmwjmvlvpozqfrvjqfg#elqfulovwjlmbqzbm#feef`wjufmlqwk#le#wkf/#tkj`k#tbp#eqlmw#le#wkflq#lwkfqtjpfplnf#elqn#lekbg#mlw#affmdfmfqbwfg#azjmelqnbwjlm-sfqnjwwfg#wljm`ovgfp#wkfgfufolsnfmw/fmwfqfg#jmwlwkf#sqfujlvp`lmpjpwfmwozbqf#hmltm#bpwkf#ejfog#lewkjp#wzsf#ledjufm#wl#wkfwkf#wjwof#le`lmwbjmp#wkfjmpwbm`fp#lejm#wkf#mlqwkgvf#wl#wkfjqbqf#gfpjdmfg`lqslqbwjlmptbp#wkbw#wkflmf#le#wkfpfnlqf#slsvobqpv``ffgfg#jmpvsslqw#eqlnjm#gjeefqfmwglnjmbwfg#azgfpjdmfg#elqltmfqpkjs#lebmg#slppjaozpwbmgbqgjyfgqfpslmpfWf{wtbp#jmwfmgfgqf`fjufg#wkfbppvnfg#wkbwbqfbp#le#wkfsqjnbqjoz#jmwkf#abpjp#lejm#wkf#pfmpfb``lvmwp#elqgfpwqlzfg#azbw#ofbpw#wtltbp#gf`obqfg`lvog#mlw#afPf`qfwbqz#lebssfbq#wl#afnbqdjm.wls92,]_p(\u007F_p(',df*xwkqlt#f~8wkf#pwbqw#lewtl#pfsbqbwfobmdvbdf#bmgtkl#kbg#affmlsfqbwjlm#legfbwk#le#wkfqfbo#mvnafqp\n?ojmh#qfo>!sqlujgfg#wkfwkf#pwlqz#le`lnsfwjwjlmpfmdojpk#+VH*fmdojpk#+VP*#evm`wjlm+*-isd!#tjgwk>!`lmejdvqbwjlm-smd!#tjgwk>!?algz#`obpp>!Nbwk-qbmgln+*`lmwfnslqbqz#Vmjwfg#Pwbwfp`jq`vnpwbm`fp-bssfmg@kjog+lqdbmjybwjlmp?psbm#`obpp>!!=?jnd#pq`>!,gjpwjmdvjpkfgwklvpbmgp#le#`lnnvmj`bwjlm`ofbq!=?,gju=jmufpwjdbwjlmebuj`lm-j`l!#nbqdjm.qjdkw9abpfg#lm#wkf#Nbppb`kvpfwwpwbaof#alqgfq>jmwfqmbwjlmbobopl#hmltm#bpsqlmvm`jbwjlmab`hdqlvmg9 esbggjmd.ofew9Elq#f{bnsof/#njp`foobmflvp%ow8,nbwk%dw8spz`kloldj`bojm#sbqwj`vobqfbq`k!#wzsf>!elqn#nfwklg>!bp#lsslpfg#wlPvsqfnf#@lvqwl``bpjlmbooz#Bggjwjlmbooz/Mlqwk#Bnfqj`bs{8ab`hdqlvmglsslqwvmjwjfpFmwfqwbjmnfmw-wlOltfq@bpf+nbmveb`wvqjmdsqlefppjlmbo#`lnajmfg#tjwkElq#jmpwbm`f/`lmpjpwjmd#le!#nb{ofmdwk>!qfwvqm#ebopf8`lmp`jlvpmfppNfgjwfqqbmfbmf{wqblqgjmbqzbppbppjmbwjlmpvapfrvfmwoz#avwwlm#wzsf>!wkf#mvnafq#lewkf#lqjdjmbo#`lnsqfkfmpjufqfefqp#wl#wkf?,vo=\t?,gju=\tskjolplskj`bool`bwjlm-kqfetbp#svaojpkfgPbm#Eqbm`jp`l+evm`wjlm+*x\t?gju#jg>!nbjmplskjpwj`bwfgnbwkfnbwj`bo#,kfbg=\u000E\t?algzpvddfpwp#wkbwgl`vnfmwbwjlm`lm`fmwqbwjlmqfobwjlmpkjspnbz#kbuf#affm+elq#f{bnsof/Wkjp#bqwj`of#jm#plnf#`bpfpsbqwp#le#wkf#gfejmjwjlm#leDqfbw#Aqjwbjm#`foosbggjmd>frvjubofmw#wlsob`fklogfq>!8#elmw.pjyf9#ivpwjej`bwjlmafojfufg#wkbwpveefqfg#eqlnbwwfnswfg#wl#ofbgfq#le#wkf`qjsw!#pq`>!,+evm`wjlm+*#xbqf#bubjobaof\t\n?ojmh#qfo>!#pq`>$kwws9,,jmwfqfpwfg#jm`lmufmwjlmbo#!#bow>!!#,=?,bqf#dfmfqboozkbp#bopl#affmnlpw#slsvobq#`lqqfpslmgjmd`qfgjwfg#tjwkwzof>!alqgfq9?,b=?,psbm=?,-dje!#tjgwk>!?jeqbnf#pq`>!wbaof#`obpp>!jmojmf.aol`h8b``lqgjmd#wl#wldfwkfq#tjwkbssql{jnbwfozsbqojbnfmwbqznlqf#bmg#nlqfgjpsobz9mlmf8wqbgjwjlmboozsqfglnjmbmwoz%maps8\u007F%maps8%maps8?,psbm=#`foopsb`jmd>?jmsvw#mbnf>!lq!#`lmwfmw>!`lmwqlufqpjbosqlsfqwz>!ld9,{.pkl`htbuf.gfnlmpwqbwjlmpvqqlvmgfg#azMfufqwkfofpp/tbp#wkf#ejqpw`lmpjgfqbaof#Bowklvdk#wkf#`loobalqbwjlmpklvog#mlw#afsqlslqwjlm#le?psbm#pwzof>!hmltm#bp#wkf#pklqwoz#bewfqelq#jmpwbm`f/gfp`qjafg#bp#,kfbg=\t?algz#pwbqwjmd#tjwkjm`qfbpjmdoz#wkf#eb`w#wkbwgjp`vppjlm#lenjggof#le#wkfbm#jmgjujgvbogjeej`vow#wl#sljmw#le#ujftklnlpf{vbojwzb``fswbm`f#le?,psbm=?,gju=nbmveb`wvqfqplqjdjm#le#wkf`lnnlmoz#vpfgjnslqwbm`f#legfmlnjmbwjlmpab`hdqlvmg9# ofmdwk#le#wkfgfwfqnjmbwjlmb#pjdmjej`bmw!#alqgfq>!3!=qfulovwjlmbqzsqjm`jsofp#lejp#`lmpjgfqfgtbp#gfufolsfgJmgl.Fvqlsfbmuvomfqbaof#wlsqlslmfmwp#lebqf#plnfwjnfp`olpfq#wl#wkfMft#Zlqh#@jwz#mbnf>!pfbq`kbwwqjavwfg#wl`lvqpf#le#wkfnbwkfnbwj`jbmaz#wkf#fmg#lebw#wkf#fmg#le!#alqgfq>!3!#wf`kmloldj`bo-qfnluf@obpp+aqbm`k#le#wkffujgfm`f#wkbw\"Xfmgje^..=\u000E\tJmpwjwvwf#le#jmwl#b#pjmdofqfpsf`wjufoz-bmg#wkfqfelqfsqlsfqwjfp#lejp#ol`bwfg#jmplnf#le#tkj`kWkfqf#jp#bopl`lmwjmvfg#wl#bssfbqbm`f#le#%bns8mgbpk8#gfp`qjafp#wkf`lmpjgfqbwjlmbvwklq#le#wkfjmgfsfmgfmwozfrvjssfg#tjwkglfp#mlw#kbuf?,b=?b#kqfe>!`lmevpfg#tjwk?ojmh#kqfe>!,bw#wkf#bdf#lebssfbq#jm#wkfWkfpf#jm`ovgfqfdbqgofpp#le`lvog#af#vpfg#pwzof>%rvlw8pfufqbo#wjnfpqfsqfpfmw#wkfalgz=\t?,kwno=wklvdkw#wl#afslsvobwjlm#leslppjajojwjfpsfq`fmwbdf#leb``fpp#wl#wkfbm#bwwfnsw#wlsqlgv`wjlm#leirvfqz,irvfqzwtl#gjeefqfmwafolmd#wl#wkffpwbaojpknfmwqfsob`jmd#wkfgfp`qjswjlm!#gfwfqnjmf#wkfbubjobaof#elqB``lqgjmd#wl#tjgf#qbmdf#le\n?gju#`obpp>!nlqf#`lnnlmozlqdbmjpbwjlmpevm`wjlmbojwztbp#`lnsofwfg#%bns8ngbpk8#sbqwj`jsbwjlmwkf#`kbqb`wfqbm#bggjwjlmbobssfbqp#wl#afeb`w#wkbw#wkfbm#f{bnsof#lepjdmjej`bmwozlmnlvpflufq>!af`bvpf#wkfz#bpzm`#>#wqvf8sqlaofnp#tjwkpffnp#wl#kbufwkf#qfpvow#le#pq`>!kwws9,,ebnjojbq#tjwkslppfppjlm#leevm`wjlm#+*#xwllh#sob`f#jmbmg#plnfwjnfppvapwbmwjbooz?psbm=?,psbm=jp#lewfm#vpfgjm#bm#bwwfnswdqfbw#gfbo#leFmujqlmnfmwbopv``fppevooz#ujqwvbooz#boo13wk#`fmwvqz/sqlefppjlmbopmf`fppbqz#wl#gfwfqnjmfg#az`lnsbwjajojwzaf`bvpf#jw#jpGj`wjlmbqz#lenlgjej`bwjlmpWkf#elooltjmdnbz#qfefq#wl9@lmpfrvfmwoz/Jmwfqmbwjlmbobowklvdk#plnfwkbw#tlvog#aftlqog$p#ejqpw`obppjejfg#bpalwwln#le#wkf+sbqwj`vobqozbojdm>!ofew!#nlpw#`lnnlmozabpjp#elq#wkfelvmgbwjlm#le`lmwqjavwjlmpslsvobqjwz#le`fmwfq#le#wkfwl#qfgv`f#wkfivqjpgj`wjlmpbssql{jnbwjlm#lmnlvpflvw>!Mft#Wfpwbnfmw`loof`wjlm#le?,psbm=?,b=?,jm#wkf#Vmjwfgejon#gjqf`wlq.pwqj`w-gwg!=kbp#affm#vpfgqfwvqm#wl#wkfbowklvdk#wkjp`kbmdf#jm#wkfpfufqbo#lwkfqavw#wkfqf#bqfvmsqf`fgfmwfgjp#pjnjobq#wlfpsf`jbooz#jmtfjdkw9#alog8jp#`boofg#wkf`lnsvwbwjlmbojmgj`bwf#wkbwqfpwqj`wfg#wl\n?nfwb#mbnf>!bqf#wzsj`booz`lmeoj`w#tjwkKltfufq/#wkf#Bm#f{bnsof#le`lnsbqfg#tjwkrvbmwjwjfp#leqbwkfq#wkbm#b`lmpwfoobwjlmmf`fppbqz#elqqfslqwfg#wkbwpsf`jej`bwjlmslojwj`bo#bmg%maps8%maps8?qfefqfm`fp#wlwkf#pbnf#zfbqDlufqmnfmw#ledfmfqbwjlm#lekbuf#mlw#affmpfufqbo#zfbqp`lnnjwnfmw#wl\n\n?vo#`obpp>!ujpvbojybwjlm2:wk#`fmwvqz/sqb`wjwjlmfqpwkbw#kf#tlvogbmg#`lmwjmvfgl``vsbwjlm#lejp#gfejmfg#bp`fmwqf#le#wkfwkf#bnlvmw#le=?gju#pwzof>!frvjubofmw#legjeefqfmwjbwfaqlvdkw#balvwnbqdjm.ofew9#bvwlnbwj`boozwklvdkw#le#bpPlnf#le#wkfpf\t?gju#`obpp>!jmsvw#`obpp>!qfsob`fg#tjwkjp#lmf#le#wkffgv`bwjlm#bmgjmeovfm`fg#azqfsvwbwjlm#bp\t?nfwb#mbnf>!b``lnnlgbwjlm?,gju=\t?,gju=obqdf#sbqw#leJmpwjwvwf#elqwkf#pl.`boofg#bdbjmpw#wkf#Jm#wkjp#`bpf/tbp#bssljmwfg`objnfg#wl#afKltfufq/#wkjpGfsbqwnfmw#lewkf#qfnbjmjmdfeef`w#lm#wkfsbqwj`vobqoz#gfbo#tjwk#wkf\t?gju#pwzof>!bonlpw#botbzpbqf#`vqqfmwozf{sqfppjlm#leskjolplskz#leelq#nlqf#wkbm`jujojybwjlmplm#wkf#jpobmgpfof`wfgJmgf{`bm#qfpvow#jm!#ubovf>!!#,=wkf#pwqv`wvqf#,=?,b=?,gju=Nbmz#le#wkfpf`bvpfg#az#wkfle#wkf#Vmjwfgpsbm#`obpp>!n`bm#af#wqb`fgjp#qfobwfg#wlaf`bnf#lmf#lejp#eqfrvfmwozojujmd#jm#wkfwkflqfwj`boozElooltjmd#wkfQfulovwjlmbqzdlufqmnfmw#jmjp#gfwfqnjmfgwkf#slojwj`bojmwqlgv`fg#jmpveej`jfmw#wlgfp`qjswjlm!=pklqw#pwlqjfppfsbqbwjlm#lebp#wl#tkfwkfqhmltm#elq#jwptbp#jmjwjboozgjpsobz9aol`hjp#bm#f{bnsofwkf#sqjm`jsbo`lmpjpwp#le#bqf`ldmjyfg#bp,algz=?,kwno=b#pvapwbmwjboqf`lmpwqv`wfgkfbg#le#pwbwfqfpjpwbm`f#wlvmgfqdqbgvbwfWkfqf#bqf#wtldqbujwbwjlmbobqf#gfp`qjafgjmwfmwjlmboozpfqufg#bp#wkf`obpp>!kfbgfqlsslpjwjlm#wlevmgbnfmwboozglnjmbwfg#wkfbmg#wkf#lwkfqboojbm`f#tjwktbp#elq`fg#wlqfpsf`wjufoz/bmg#slojwj`bojm#pvsslqw#lesflsof#jm#wkf13wk#`fmwvqz-bmg#svaojpkfgolbg@kbqwafbwwl#vmgfqpwbmgnfnafq#pwbwfpfmujqlmnfmwboejqpw#kboe#le`lvmwqjfp#bmgbq`kjwf`wvqboaf#`lmpjgfqfg`kbqb`wfqjyfg`ofbqJmwfqubobvwklqjwbwjufEfgfqbwjlm#letbp#pv``ffgfgbmg#wkfqf#bqfb#`lmpfrvfm`fwkf#Sqfpjgfmwbopl#jm`ovgfgeqff#plewtbqfpv``fppjlm#legfufolsfg#wkftbp#gfpwqlzfgbtbz#eqln#wkf8\t?,p`qjsw=\t?bowklvdk#wkfzelooltfg#az#bnlqf#sltfqevoqfpvowfg#jm#bVmjufqpjwz#leKltfufq/#nbmzwkf#sqfpjgfmwKltfufq/#plnfjp#wklvdkw#wlvmwjo#wkf#fmgtbp#bmmlvm`fgbqf#jnslqwbmwbopl#jm`ovgfp=?jmsvw#wzsf>wkf#`fmwfq#le#GL#MLW#BOWFQvpfg#wl#qfefqwkfnfp,wkbw#kbg#affmwkf#abpjp#elqkbp#gfufolsfgjm#wkf#pvnnfq`lnsbqbwjufozgfp`qjafg#wkfpv`k#bp#wklpfwkf#qfpvowjmdjp#jnslppjaofubqjlvp#lwkfqPlvwk#Beqj`bmkbuf#wkf#pbnffeef`wjufmfppjm#tkj`k#`bpf8#wf{w.bojdm9pwqv`wvqf#bmg8#ab`hdqlvmg9qfdbqgjmd#wkfpvsslqwfg#wkfjp#bopl#hmltmpwzof>!nbqdjmjm`ovgjmd#wkfabkbpb#Nfobzvmlqph#alhn/Iomlqph#mzmlqphpolufm)M(ajmbjmwfqmb`jlmbo`bojej`b`j/_m`lnvmj`b`j/_m`lmpwqv``j/_m!=?gju#`obpp>!gjpbnajdvbwjlmGlnbjmMbnf$/#$bgnjmjpwqbwjlmpjnvowbmflvpozwqbmpslqwbwjlmJmwfqmbwjlmbo#nbqdjm.alwwln9qfpslmpjajojwz?\"Xfmgje^..=\t?,=?nfwb#mbnf>!jnsofnfmwbwjlmjmeqbpwqv`wvqfqfsqfpfmwbwjlmalqgfq.alwwln9?,kfbg=\t?algz=>kwws&0B&1E&1E?elqn#nfwklg>!nfwklg>!slpw!#,ebuj`lm-j`l!#~*8\t?,p`qjsw=\t-pfwBwwqjavwf+Bgnjmjpwqbwjlm>#mft#Bqqbz+*8?\"Xfmgje^..=\u000E\tgjpsobz9aol`h8Vmelqwvmbwfoz/!=%maps8?,gju=,ebuj`lm-j`l!=>$pwzofpkffw$#jgfmwjej`bwjlm/#elq#f{bnsof/?oj=?b#kqfe>!,bm#bowfqmbwjufbp#b#qfpvow#lesw!=?,p`qjsw=\twzsf>!pvanjw!#\t+evm`wjlm+*#xqf`lnnfmgbwjlmelqn#b`wjlm>!,wqbmpelqnbwjlmqf`lmpwqv`wjlm-pwzof-gjpsobz#B``lqgjmd#wl#kjggfm!#mbnf>!bolmd#tjwk#wkfgl`vnfmw-algz-bssql{jnbwfoz#@lnnvmj`bwjlmpslpw!#b`wjlm>!nfbmjmd#%rvlw8..?\"Xfmgje^..=Sqjnf#Njmjpwfq`kbqb`wfqjpwj`?,b=#?b#`obpp>wkf#kjpwlqz#le#lmnlvpflufq>!wkf#dlufqmnfmwkqfe>!kwwsp9,,tbp#lqjdjmbooztbp#jmwqlgv`fg`obppjej`bwjlmqfsqfpfmwbwjufbqf#`lmpjgfqfg?\"Xfmgje^..=\t\tgfsfmgp#lm#wkfVmjufqpjwz#le#jm#`lmwqbpw#wl#sob`fklogfq>!jm#wkf#`bpf#lejmwfqmbwjlmbo#`lmpwjwvwjlmbopwzof>!alqgfq.9#evm`wjlm+*#xAf`bvpf#le#wkf.pwqj`w-gwg!=\t?wbaof#`obpp>!b``lnsbmjfg#azb``lvmw#le#wkf?p`qjsw#pq`>!,mbwvqf#le#wkf#wkf#sflsof#jm#jm#bggjwjlm#wlp*8#ip-jg#>#jg!#tjgwk>!233&!qfdbqgjmd#wkf#Qlnbm#@bwkloj`bm#jmgfsfmgfmwelooltjmd#wkf#-dje!#tjgwk>!2wkf#elooltjmd#gjp`qjnjmbwjlmbq`kbfloldj`bosqjnf#njmjpwfq-ip!=?,p`qjsw=`lnajmbwjlm#le#nbqdjmtjgwk>!`qfbwfFofnfmw+t-bwwb`kFufmw+?,b=?,wg=?,wq=pq`>!kwwsp9,,bJm#sbqwj`vobq/#bojdm>!ofew!#@yf`k#Qfsvaoj`Vmjwfg#Hjmdgln`lqqfpslmgfm`f`lm`ovgfg#wkbw-kwno!#wjwof>!+evm`wjlm#+*#x`lnfp#eqln#wkfbssoj`bwjlm#le?psbm#`obpp>!pafojfufg#wl#affnfmw+$p`qjsw$?,b=\t?,oj=\t?ojufqz#gjeefqfmw=?psbm#`obpp>!lswjlm#ubovf>!+bopl#hmltm#bp\n?oj=?b#kqfe>!=?jmsvw#mbnf>!pfsbqbwfg#eqlnqfefqqfg#wl#bp#ubojdm>!wls!=elvmgfq#le#wkfbwwfnswjmd#wl#`bqalm#gjl{jgf\t\t?gju#`obpp>!`obpp>!pfbq`k.,algz=\t?,kwno=lsslqwvmjwz#wl`lnnvmj`bwjlmp?,kfbg=\u000E\t?algz#pwzof>!tjgwk9Wj\rVSmd#Uj\rWkw`kbmdfp#jm#wkfalqgfq.`lolq9 3!#alqgfq>!3!#?,psbm=?,gju=?tbp#gjp`lufqfg!#wzsf>!wf{w!#*8\t?,p`qjsw=\t\tGfsbqwnfmw#le#f``ofpjbpwj`bowkfqf#kbp#affmqfpvowjmd#eqln?,algz=?,kwno=kbp#mfufq#affmwkf#ejqpw#wjnfjm#qfpslmpf#wlbvwlnbwj`booz#?,gju=\t\t?gju#jtbp#`lmpjgfqfgsfq`fmw#le#wkf!#,=?,b=?,gju=`loof`wjlm#le#gfp`fmgfg#eqlnpf`wjlm#le#wkfb``fsw.`kbqpfwwl#af#`lmevpfgnfnafq#le#wkf#sbggjmd.qjdkw9wqbmpobwjlm#lejmwfqsqfwbwjlm#kqfe>$kwws9,,tkfwkfq#lq#mlwWkfqf#bqf#boplwkfqf#bqf#nbmzb#pnboo#mvnafqlwkfq#sbqwp#lejnslppjaof#wl##`obpp>!avwwlmol`bwfg#jm#wkf-#Kltfufq/#wkfbmg#fufmwvboozBw#wkf#fmg#le#af`bvpf#le#jwpqfsqfpfmwp#wkf?elqn#b`wjlm>!#nfwklg>!slpw!jw#jp#slppjaofnlqf#ojhfoz#wlbm#jm`qfbpf#jmkbuf#bopl#affm`lqqfpslmgp#wlbmmlvm`fg#wkbwbojdm>!qjdkw!=nbmz#`lvmwqjfpelq#nbmz#zfbqpfbqojfpw#hmltmaf`bvpf#jw#tbpsw!=?,p`qjsw=\u000E#ubojdm>!wls!#jmkbajwbmwp#leelooltjmd#zfbq\u000E\t?gju#`obpp>!njoojlm#sflsof`lmwqlufqpjbo#`lm`fqmjmd#wkfbqdvf#wkbw#wkfdlufqmnfmw#bmgb#qfefqfm`f#wlwqbmpefqqfg#wlgfp`qjajmd#wkf#pwzof>!`lolq9bowklvdk#wkfqfafpw#hmltm#elqpvanjw!#mbnf>!nvowjsoj`bwjlmnlqf#wkbm#lmf#qf`ldmjwjlm#le@lvm`jo#le#wkffgjwjlm#le#wkf##?nfwb#mbnf>!Fmwfqwbjmnfmw#btbz#eqln#wkf#8nbqdjm.qjdkw9bw#wkf#wjnf#lejmufpwjdbwjlmp`lmmf`wfg#tjwkbmg#nbmz#lwkfqbowklvdk#jw#jpafdjmmjmd#tjwk#?psbm#`obpp>!gfp`fmgbmwp#le?psbm#`obpp>!j#bojdm>!qjdkw!?,kfbg=\t?algz#bpsf`wp#le#wkfkbp#pjm`f#affmFvqlsfbm#Vmjlmqfnjmjp`fmw#lenlqf#gjeej`vowUj`f#Sqfpjgfmw`lnslpjwjlm#lesbppfg#wkqlvdknlqf#jnslqwbmwelmw.pjyf922s{f{sobmbwjlm#lewkf#`lm`fsw#letqjwwfm#jm#wkf\n?psbm#`obpp>!jp#lmf#le#wkf#qfpfnaobm`f#wllm#wkf#dqlvmgptkj`k#`lmwbjmpjm`ovgjmd#wkf#gfejmfg#az#wkfsvaoj`bwjlm#lenfbmp#wkbw#wkflvwpjgf#le#wkfpvsslqw#le#wkf?jmsvw#`obpp>!?psbm#`obpp>!w+Nbwk-qbmgln+*nlpw#sqlnjmfmwgfp`qjswjlm#le@lmpwbmwjmlsoftfqf#svaojpkfg?gju#`obpp>!pfbssfbqp#jm#wkf2!#kfjdkw>!2!#nlpw#jnslqwbmwtkj`k#jm`ovgfptkj`k#kbg#affmgfpwqv`wjlm#lewkf#slsvobwjlm\t\n?gju#`obpp>!slppjajojwz#leplnfwjnfp#vpfgbssfbq#wl#kbufpv``fpp#le#wkfjmwfmgfg#wl#afsqfpfmw#jm#wkfpwzof>!`ofbq9a\u000E\t?,p`qjsw=\u000E\t?tbp#elvmgfg#jmjmwfqujft#tjwk\\jg!#`lmwfmw>!`bsjwbo#le#wkf\u000E\t?ojmh#qfo>!pqfofbpf#le#wkfsljmw#lvw#wkbw{NOKwwsQfrvfpwbmg#pvapfrvfmwpf`lmg#obqdfpwufqz#jnslqwbmwpsf`jej`bwjlmppvqeb`f#le#wkfbssojfg#wl#wkfelqfjdm#sloj`z\\pfwGlnbjmMbnffpwbaojpkfg#jmjp#afojfufg#wlJm#bggjwjlm#wlnfbmjmd#le#wkfjp#mbnfg#bewfqwl#sqlwf`w#wkfjp#qfsqfpfmwfgGf`obqbwjlm#lenlqf#feej`jfmw@obppjej`bwjlmlwkfq#elqnp#lekf#qfwvqmfg#wl?psbm#`obpp>!`sfqelqnbm`f#le+evm`wjlm+*#x\u000Eje#bmg#lmoz#jeqfdjlmp#le#wkfofbgjmd#wl#wkfqfobwjlmp#tjwkVmjwfg#Mbwjlmppwzof>!kfjdkw9lwkfq#wkbm#wkfzsf!#`lmwfmw>!Bppl`jbwjlm#le\t?,kfbg=\t?algzol`bwfg#lm#wkfjp#qfefqqfg#wl+jm`ovgjmd#wkf`lm`fmwqbwjlmpwkf#jmgjujgvbobnlmd#wkf#nlpwwkbm#bmz#lwkfq,=\t?ojmh#qfo>!#qfwvqm#ebopf8wkf#svqslpf#lewkf#bajojwz#wl8`lolq9 eee~\t-\t?psbm#`obpp>!wkf#pvaif`w#legfejmjwjlmp#le=\u000E\t?ojmh#qfo>!`objn#wkbw#wkfkbuf#gfufolsfg?wbaof#tjgwk>!`fofaqbwjlm#leElooltjmd#wkf#wl#gjpwjmdvjpk?psbm#`obpp>!awbhfp#sob`f#jmvmgfq#wkf#mbnfmlwfg#wkbw#wkf=?\"Xfmgje^..=\tpwzof>!nbqdjm.jmpwfbg#le#wkfjmwqlgv`fg#wkfwkf#sql`fpp#lejm`qfbpjmd#wkfgjeefqfm`fp#jmfpwjnbwfg#wkbwfpsf`jbooz#wkf,gju=?gju#jg>!tbp#fufmwvboozwkqlvdklvw#kjpwkf#gjeefqfm`fplnfwkjmd#wkbwpsbm=?,psbm=?,pjdmjej`bmwoz#=?,p`qjsw=\u000E\t\u000E\tfmujqlmnfmwbo#wl#sqfufmw#wkfkbuf#affm#vpfgfpsf`jbooz#elqvmgfqpwbmg#wkfjp#fppfmwjbooztfqf#wkf#ejqpwjp#wkf#obqdfpwkbuf#affm#nbgf!#pq`>!kwws9,,jmwfqsqfwfg#bppf`lmg#kboe#le`qloojmd>!ml!#jp#`lnslpfg#leJJ/#Kloz#Qlnbmjp#f{sf`wfg#wlkbuf#wkfjq#ltmgfejmfg#bp#wkfwqbgjwjlmbooz#kbuf#gjeefqfmwbqf#lewfm#vpfgwl#fmpvqf#wkbwbdqffnfmw#tjwk`lmwbjmjmd#wkfbqf#eqfrvfmwozjmelqnbwjlm#lmf{bnsof#jp#wkfqfpvowjmd#jm#b?,b=?,oj=?,vo=#`obpp>!ellwfqbmg#fpsf`jboozwzsf>!avwwlm!#?,psbm=?,psbm=tkj`k#jm`ovgfg=\t?nfwb#mbnf>!`lmpjgfqfg#wkf`bqqjfg#lvw#azKltfufq/#jw#jpaf`bnf#sbqw#lejm#qfobwjlm#wlslsvobq#jm#wkfwkf#`bsjwbo#letbp#leej`jbooztkj`k#kbp#affmwkf#Kjpwlqz#lebowfqmbwjuf#wlgjeefqfmw#eqlnwl#pvsslqw#wkfpvddfpwfg#wkbwjm#wkf#sql`fpp##?gju#`obpp>!wkf#elvmgbwjlmaf`bvpf#le#kjp`lm`fqmfg#tjwkwkf#vmjufqpjwzlsslpfg#wl#wkfwkf#`lmwf{w#le?psbm#`obpp>!swf{w!#mbnf>!r!\n\n?gju#`obpp>!wkf#p`jfmwjej`qfsqfpfmwfg#aznbwkfnbwj`jbmpfof`wfg#az#wkfwkbw#kbuf#affm=?gju#`obpp>!`gju#jg>!kfbgfqjm#sbqwj`vobq/`lmufqwfg#jmwl*8\t?,p`qjsw=\t?skjolplskj`bo#pqsphlkqubwphjwj\rVSmd#Uj\rWkw!kwws9,,!=?psbm#`obpp>!nfnafqp#le#wkf#tjmglt-ol`bwjlmufqwj`bo.bojdm9,b=#\u007F#?b#kqfe>!?\"gl`wzsf#kwno=nfgjb>!p`qffm!#?lswjlm#ubovf>!ebuj`lm-j`l!#,=\t\n\n?gju#`obpp>!`kbqb`wfqjpwj`p!#nfwklg>!dfw!#,algz=\t?,kwno=\tpklqw`vw#j`lm!#gl`vnfmw-tqjwf+sbggjmd.alwwln9qfsqfpfmwbwjufppvanjw!#ubovf>!bojdm>!`fmwfq!#wkqlvdklvw#wkf#p`jfm`f#ej`wjlm\t##?gju#`obpp>!pvanjw!#`obpp>!lmf#le#wkf#nlpw#ubojdm>!wls!=?tbp#fpwbaojpkfg*8\u000E\t?,p`qjsw=\u000E\tqfwvqm#ebopf8!=*-pwzof-gjpsobzaf`bvpf#le#wkf#gl`vnfmw-`llhjf?elqn#b`wjlm>!,~algzxnbqdjm938Fm`z`olsfgjb#leufqpjlm#le#wkf#-`qfbwfFofnfmw+mbnf!#`lmwfmw>!?,gju=\t?,gju=\t\tbgnjmjpwqbwjuf#?,algz=\t?,kwno=kjpwlqz#le#wkf#!=?jmsvw#wzsf>!slqwjlm#le#wkf#bp#sbqw#le#wkf#%maps8?b#kqfe>!lwkfq#`lvmwqjfp!=\t?gju#`obpp>!?,psbm=?,psbm=?Jm#lwkfq#tlqgp/gjpsobz9#aol`h8`lmwqlo#le#wkf#jmwqlgv`wjlm#le,=\t?nfwb#mbnf>!bp#tfoo#bp#wkf#jm#qf`fmw#zfbqp\u000E\t\n?gju#`obpp>!?,gju=\t\n?,gju=\tjmpsjqfg#az#wkfwkf#fmg#le#wkf#`lnsbwjaof#tjwkaf`bnf#hmltm#bp#pwzof>!nbqdjm9-ip!=?,p`qjsw=?#Jmwfqmbwjlmbo#wkfqf#kbuf#affmDfqnbm#obmdvbdf#pwzof>!`lolq9 @lnnvmjpw#Sbqwz`lmpjpwfmw#tjwkalqgfq>!3!#`foo#nbqdjmkfjdkw>!wkf#nbilqjwz#le!#bojdm>!`fmwfqqfobwfg#wl#wkf#nbmz#gjeefqfmw#Lqwklgl{#@kvq`kpjnjobq#wl#wkf#,=\t?ojmh#qfo>!ptbp#lmf#le#wkf#vmwjo#kjp#gfbwk~*+*8\t?,p`qjsw=lwkfq#obmdvbdfp`lnsbqfg#wl#wkfslqwjlmp#le#wkfwkf#Mfwkfqobmgpwkf#nlpw#`lnnlmab`hdqlvmg9vqo+bqdvfg#wkbw#wkfp`qloojmd>!ml!#jm`ovgfg#jm#wkfMlqwk#Bnfqj`bm#wkf#mbnf#le#wkfjmwfqsqfwbwjlmpwkf#wqbgjwjlmbogfufolsnfmw#le#eqfrvfmwoz#vpfgb#`loof`wjlm#leufqz#pjnjobq#wlpvqqlvmgjmd#wkff{bnsof#le#wkjpbojdm>!`fmwfq!=tlvog#kbuf#affmjnbdf\\`bswjlm#>bwwb`kfg#wl#wkfpvddfpwjmd#wkbwjm#wkf#elqn#le#jmuloufg#jm#wkfjp#gfqjufg#eqlnmbnfg#bewfq#wkfJmwqlgv`wjlm#wlqfpwqj`wjlmp#lm#pwzof>!tjgwk9#`bm#af#vpfg#wl#wkf#`qfbwjlm#lenlpw#jnslqwbmw#jmelqnbwjlm#bmgqfpvowfg#jm#wkf`loobspf#le#wkfWkjp#nfbmp#wkbwfofnfmwp#le#wkftbp#qfsob`fg#azbmbozpjp#le#wkfjmpsjqbwjlm#elqqfdbqgfg#bp#wkfnlpw#pv``fppevohmltm#bp#%rvlw8b#`lnsqfkfmpjufKjpwlqz#le#wkf#tfqf#`lmpjgfqfgqfwvqmfg#wl#wkfbqf#qfefqqfg#wlVmplvq`fg#jnbdf=\t\n?gju#`obpp>!`lmpjpwp#le#wkfpwlsSqlsbdbwjlmjmwfqfpw#jm#wkfbubjobajojwz#lebssfbqp#wl#kbuffof`wqlnbdmfwj`fmbaofPfquj`fp+evm`wjlm#le#wkfJw#jp#jnslqwbmw?,p`qjsw=?,gju=evm`wjlm+*xubq#qfobwjuf#wl#wkfbp#b#qfpvow#le#wkf#slpjwjlm#leElq#f{bnsof/#jm#nfwklg>!slpw!#tbp#elooltfg#az%bns8ngbpk8#wkfwkf#bssoj`bwjlmip!=?,p`qjsw=\u000E\tvo=?,gju=?,gju=bewfq#wkf#gfbwktjwk#qfpsf`w#wlpwzof>!sbggjmd9jp#sbqwj`vobqozgjpsobz9jmojmf8#wzsf>!pvanjw!#jp#gjujgfg#jmwl\bTA\nzk#+\u000BBl\bQ\u007F*qfpslmpbajojgbgbgnjmjpwqb`j/_mjmwfqmb`jlmbofp`lqqfpslmgjfmwf\fHe\fHF\fHC\fIg\fH{\fHF\fIn\fH\\\fIa\fHY\fHU\fHB\fHR\fH\\\fIk\fH^\fIg\fH{\fIg\fHn\fHv\fIm\fHD\fHR\fHY\fH^\fIk\fHy\fHS\fHD\fHT\fH\\\fHy\fHR\fH\\\fHF\fIm\fH^\fHS\fHT\fHz\fIg\fHp\fIk\fHn\fHv\fHR\fHU\fHS\fHc\fHA\fIk\fHp\fIk\fHn\fHZ\fHR\fHB\fHS\fH^\fHU\fHB\fHR\fH\\\fIl\fHp\fHR\fH{\fH\\\fHO\fH@\fHD\fHR\fHD\fIk\fHy\fIm\fHB\fHR\fH\\\fH@\fIa\fH^\fIe\fH{\fHB\fHR\fH^\fHS\fHy\fHB\fHU\fHS\fH^\fHR\fHF\fIo\fH[\fIa\fHL\fH@\fHN\fHP\fHH\fIk\fHA\fHR\fHp\fHF\fHR\fHy\fIa\fH^\fHS\fHy\fHs\fIa\fH\\\fIk\fHD\fHz\fHS\fH^\fHR\fHG\fHJ\fI`\fH\\\fHR\fHD\fHB\fHR\fHB\fH^\fIk\fHB\fHH\fHJ\fHR\fHD\fH@\fHR\fHp\fHR\fH\\\fHY\fHS\fHy\fHR\fHT\fHy\fIa\fHC\fIg\fHn\fHv\fHR\fHU\fHH\fIk\fHF\fHU\fIm\fHm\fHv\fH@\fHH\fHR\fHC\fHR\fHT\fHn\fHY\fHR\fHJ\fHJ\fIk\fHz\fHD\fIk\fHF\fHS\fHw\fH^\fIk\fHY\fHS\fHZ\fIk\fH[\fH\\\fHR\fHp\fIa\fHC\fHe\fHH\fIa\fHH\fH\\\fHB\fIm\fHn\fH@\fHd\fHJ\fIg\fHD\fIg\fHn\fHe\fHF\fHy\fH\\\fHO\fHF\fHN\fHP\fIk\fHn\fHT\fIa\fHI\fHS\fHH\fHG\fHS\fH^\fIa\fHB\fHB\fIm\fHz\fIa\fHC\fHi\fHv\fIa\fHw\fHR\fHw\fIn\fHs\fHH\fIl\fHT\fHn\fH{\fIl\fHH\fHp\fHR\fHc\fH{\fHR\fHY\fHS\fHA\fHR\fH{\fHt\fHO\fIa\fHs\fIk\fHJ\fIn\fHT\fH\\\fIk\fHJ\fHS\fHD\fIg\fHn\fHU\fHH\fIa\fHC\fHR\fHT\fIk\fHy\fIa\fHT\fH{\fHR\fHn\fHK\fIl\fHY\fHS\fHZ\fIa\fHY\fH\\\fHR\fHH\fIk\fHn\fHJ\fId\fHs\fIa\fHT\fHD\fHy\fIa\fHZ\fHR\fHT\fHR\fHB\fHD\fIk\fHi\fHJ\fHR\fH^\fHH\fH@\fHS\fHp\fH^\fIl\fHF\fIm\fH\\\fIn\fH[\fHU\fHS\fHn\fHJ\fIl\fHB\fHS\fHH\fIa\fH\\\fHy\fHY\fHS\fHH\fHR\fH\\\fIm\fHF\fHC\fIk\fHT\fIa\fHI\fHR\fHD\fHy\fH\\\fIg\fHM\fHP\fHB\fIm\fHy\fIa\fHH\fHC\fIg\fHp\fHD\fHR\fHy\fIo\fHF\fHC\fHR\fHF\fIg\fHT\fIa\fHs\fHt\fH\\\fIk\fH^\fIn\fHy\fHR\fH\\\fIa\fHC\fHY\fHS\fHv\fHR\fH\\\fHT\fIn\fHv\fHD\fHR\fHB\fIn\fH^\fIa\fHC\fHJ\fIk\fHz\fIk\fHn\fHU\fHB\fIk\fHZ\fHR\fHT\fIa\fHy\fIn\fH^\fHB\fId\fHn\fHD\fIk\fHH\fId\fHC\fHR\fH\\\fHp\fHS\fHT\fHy\fIkqpp({no!#wjwof>!.wzsf!#`lmwfmw>!wjwof!#`lmwfmw>!bw#wkf#pbnf#wjnf-ip!=?,p`qjsw=\t?!#nfwklg>!slpw!#?,psbm=?,b=?,oj=ufqwj`bo.bojdm9w,irvfqz-njm-ip!=-`oj`h+evm`wjlm+#pwzof>!sbggjmd.~*+*8\t?,p`qjsw=\t?,psbm=?b#kqfe>!?b#kqfe>!kwws9,,*8#qfwvqm#ebopf8wf{w.gf`lqbwjlm9#p`qloojmd>!ml!#alqgfq.`loobspf9bppl`jbwfg#tjwk#Abkbpb#JmglmfpjbFmdojpk#obmdvbdf?wf{w#{no9psb`f>-dje!#alqgfq>!3!?,algz=\t?,kwno=\tlufqeolt9kjggfm8jnd#pq`>!kwws9,,bggFufmwOjpwfmfqqfpslmpjaof#elq#p-ip!=?,p`qjsw=\t,ebuj`lm-j`l!#,=lsfqbwjmd#pzpwfn!#pwzof>!tjgwk92wbqdfw>!\\aobmh!=Pwbwf#Vmjufqpjwzwf{w.bojdm9ofew8\tgl`vnfmw-tqjwf+/#jm`ovgjmd#wkf#bqlvmg#wkf#tlqog*8\u000E\t?,p`qjsw=\u000E\t?!#pwzof>!kfjdkw98lufqeolt9kjggfmnlqf#jmelqnbwjlmbm#jmwfqmbwjlmbob#nfnafq#le#wkf#lmf#le#wkf#ejqpw`bm#af#elvmg#jm#?,gju=\t\n\n?,gju=\tgjpsobz9#mlmf8!=!#,=\t?ojmh#qfo>!\t##+evm`wjlm+*#xwkf#26wk#`fmwvqz-sqfufmwGfebvow+obqdf#mvnafq#le#Azybmwjmf#Fnsjqf-isd\u007Fwkvna\u007Fofew\u007Fubpw#nbilqjwz#lenbilqjwz#le#wkf##bojdm>!`fmwfq!=Vmjufqpjwz#Sqfppglnjmbwfg#az#wkfPf`lmg#Tlqog#Tbqgjpwqjavwjlm#le#pwzof>!slpjwjlm9wkf#qfpw#le#wkf#`kbqb`wfqjyfg#az#qfo>!mleloolt!=gfqjufp#eqln#wkfqbwkfq#wkbm#wkf#b#`lnajmbwjlm#lepwzof>!tjgwk9233Fmdojpk.psfbhjmd`lnsvwfq#p`jfm`falqgfq>!3!#bow>!wkf#f{jpwfm`f#leGfnl`qbwj`#Sbqwz!#pwzof>!nbqdjm.Elq#wkjp#qfbplm/-ip!=?,p`qjsw=\t\npAzWbdMbnf+p*X3^ip!=?,p`qjsw=\u000E\t?-ip!=?,p`qjsw=\u000E\tojmh#qfo>!j`lm!#$#bow>$$#`obpp>$elqnbwjlm#le#wkfufqpjlmp#le#wkf#?,b=?,gju=?,gju=,sbdf=\t##?sbdf=\t?gju#`obpp>!`lmwaf`bnf#wkf#ejqpwabkbpb#Jmglmfpjbfmdojpk#+pjnsof*\"y\"W\"W\"[\"Q\"U\"V\"@=i=l<^<\\=n=m!?gju#jg>!ellwfq!=wkf#Vmjwfg#Pwbwfp?jnd#pq`>!kwws9,,-isd\u007Fqjdkw\u007Fwkvna\u007F-ip!=?,p`qjsw=\u000E\t?ol`bwjlm-sqlwl`loeqbnfalqgfq>!3!#p!#,=\t?nfwb#mbnf>!?,b=?,gju=?,gju=?elmw.tfjdkw9alog8%rvlw8#bmg#%rvlw8gfsfmgjmd#lm#wkf#nbqdjm938sbggjmd9!#qfo>!mleloolt!#Sqfpjgfmw#le#wkf#wtfmwjfwk#`fmwvqzfujpjlm=\t##?,sbdfJmwfqmfw#F{solqfqb-bpzm`#>#wqvf8\u000E\tjmelqnbwjlm#balvw?gju#jg>!kfbgfq!=!#b`wjlm>!kwws9,,?b#kqfe>!kwwsp9,,?gju#jg>!`lmwfmw!?,gju=\u000E\t?,gju=\u000E\t?gfqjufg#eqln#wkf#?jnd#pq`>$kwws9,,b``lqgjmd#wl#wkf#\t?,algz=\t?,kwno=\tpwzof>!elmw.pjyf9p`qjsw#obmdvbdf>!Bqjbo/#Kfoufwj`b/?,b=?psbm#`obpp>!?,p`qjsw=?p`qjsw#slojwj`bo#sbqwjfpwg=?,wq=?,wbaof=?kqfe>!kwws9,,ttt-jmwfqsqfwbwjlm#leqfo>!pwzofpkffw!#gl`vnfmw-tqjwf+$?`kbqpfw>!vwe.;!=\tafdjmmjmd#le#wkf#qfufbofg#wkbw#wkfwfofujpjlm#pfqjfp!#qfo>!mleloolt!=#wbqdfw>!\\aobmh!=`objnjmd#wkbw#wkfkwws&0B&1E&1Ettt-nbmjefpwbwjlmp#leSqjnf#Njmjpwfq#lejmeovfm`fg#az#wkf`obpp>!`ofbqej{!=,gju=\u000E\t?,gju=\u000E\t\u000E\twkqff.gjnfmpjlmbo@kvq`k#le#Fmdobmgle#Mlqwk#@bqlojmbprvbqf#hjolnfwqfp-bggFufmwOjpwfmfqgjpwjm`w#eqln#wkf`lnnlmoz#hmltm#bpSklmfwj`#Boskbafwgf`obqfg#wkbw#wkf`lmwqloofg#az#wkfAfmibnjm#Eqbmhojmqlof.sobzjmd#dbnfwkf#Vmjufqpjwz#lejm#Tfpwfqm#Fvqlsfsfqplmbo#`lnsvwfqSqlif`w#Dvwfmafqdqfdbqgofpp#le#wkfkbp#affm#sqlslpfgwldfwkfq#tjwk#wkf=?,oj=?oj#`obpp>!jm#plnf#`lvmwqjfpnjm-ip!=?,p`qjsw=le#wkf#slsvobwjlmleej`jbo#obmdvbdf?jnd#pq`>!jnbdfp,jgfmwjejfg#az#wkfmbwvqbo#qfplvq`fp`obppjej`bwjlm#le`bm#af#`lmpjgfqfgrvbmwvn#nf`kbmj`pMfufqwkfofpp/#wkfnjoojlm#zfbqp#bdl?,algz=\u000E\t?,kwno=\u000E\"y\"W\"W\"[\"Q\"U\"V\"@\twbhf#bgubmwbdf#lebmg/#b``lqgjmd#wlbwwqjavwfg#wl#wkfNj`qlplew#Tjmgltpwkf#ejqpw#`fmwvqzvmgfq#wkf#`lmwqlogju#`obpp>!kfbgfqpklqwoz#bewfq#wkfmlwbaof#f{`fswjlmwfmp#le#wklvpbmgppfufqbo#gjeefqfmwbqlvmg#wkf#tlqog-qfb`kjmd#njojwbqzjplobwfg#eqln#wkflsslpjwjlm#wl#wkfwkf#Log#WfpwbnfmwBeqj`bm#Bnfqj`bmpjmpfqwfg#jmwl#wkfpfsbqbwf#eqln#wkfnfwqlslojwbm#bqfbnbhfp#jw#slppjaofb`hmltofgdfg#wkbwbqdvbaoz#wkf#nlpwwzsf>!wf{w,`pp!=\twkf#JmwfqmbwjlmboB``lqgjmd#wl#wkf#sf>!wf{w,`pp!#,=\t`ljm`jgf#tjwk#wkfwtl.wkjqgp#le#wkfGvqjmd#wkjp#wjnf/gvqjmd#wkf#sfqjlgbmmlvm`fg#wkbw#kfwkf#jmwfqmbwjlmbobmg#nlqf#qf`fmwozafojfufg#wkbw#wkf`lmp`jlvpmfpp#bmgelqnfqoz#hmltm#bppvqqlvmgfg#az#wkfejqpw#bssfbqfg#jml``bpjlmbooz#vpfgslpjwjlm9baplovwf8!#wbqdfw>!\\aobmh!#slpjwjlm9qfobwjuf8wf{w.bojdm9`fmwfq8ib{,ojap,irvfqz,2-ab`hdqlvmg.`lolq9 wzsf>!bssoj`bwjlm,bmdvbdf!#`lmwfmw>!?nfwb#kwws.frvju>!Sqjub`z#Sloj`z?,b=f+!&0@p`qjsw#pq`>$!#wbqdfw>!\\aobmh!=Lm#wkf#lwkfq#kbmg/-isd\u007Fwkvna\u007Fqjdkw\u007F1?,gju=?gju#`obpp>!?gju#pwzof>!eolbw9mjmfwffmwk#`fmwvqz?,algz=\u000E\t?,kwno=\u000E\t?jnd#pq`>!kwws9,,p8wf{w.bojdm9`fmwfqelmw.tfjdkw9#alog8#B``lqgjmd#wl#wkf#gjeefqfm`f#afwtffm!#eqbnfalqgfq>!3!#!#pwzof>!slpjwjlm9ojmh#kqfe>!kwws9,,kwno7,ollpf-gwg!=\tgvqjmd#wkjp#sfqjlg?,wg=?,wq=?,wbaof=`olpfoz#qfobwfg#wlelq#wkf#ejqpw#wjnf8elmw.tfjdkw9alog8jmsvw#wzsf>!wf{w!#?psbm#pwzof>!elmw.lmqfbgzpwbwf`kbmdf\n?gju#`obpp>!`ofbqgl`vnfmw-ol`bwjlm-#Elq#f{bnsof/#wkf#b#tjgf#ubqjfwz#le#?\"GL@WZSF#kwno=\u000E\t?%maps8%maps8%maps8!=?b#kqfe>!kwws9,,pwzof>!eolbw9ofew8`lm`fqmfg#tjwk#wkf>kwws&0B&1E&1Ettt-jm#slsvobq#`vowvqfwzsf>!wf{w,`pp!#,=jw#jp#slppjaof#wl#Kbqubqg#Vmjufqpjwzwzofpkffw!#kqfe>!,wkf#nbjm#`kbqb`wfqL{elqg#Vmjufqpjwz##mbnf>!hfztlqgp!#`pwzof>!wf{w.bojdm9wkf#Vmjwfg#Hjmdglnefgfqbo#dlufqmnfmw?gju#pwzof>!nbqdjm#gfsfmgjmd#lm#wkf#gfp`qjswjlm#le#wkf?gju#`obpp>!kfbgfq-njm-ip!=?,p`qjsw=gfpwqv`wjlm#le#wkfpojdkwoz#gjeefqfmwjm#b``lqgbm`f#tjwkwfof`lnnvmj`bwjlmpjmgj`bwfp#wkbw#wkfpklqwoz#wkfqfbewfqfpsf`jbooz#jm#wkf#Fvqlsfbm#`lvmwqjfpKltfufq/#wkfqf#bqfpq`>!kwws9,,pwbwj`pvddfpwfg#wkbw#wkf!#pq`>!kwws9,,ttt-b#obqdf#mvnafq#le#Wfof`lnnvmj`bwjlmp!#qfo>!mleloolt!#wKloz#Qlnbm#Fnsfqlqbonlpw#f{`ovpjufoz!#alqgfq>!3!#bow>!Pf`qfwbqz#le#Pwbwf`vonjmbwjmd#jm#wkf@JB#Tlqog#Eb`wallhwkf#nlpw#jnslqwbmwbmmjufqpbqz#le#wkfpwzof>!ab`hdqlvmg.?oj=?fn=?b#kqfe>!,wkf#Bwobmwj`#L`fbmpwqj`woz#psfbhjmd/pklqwoz#afelqf#wkfgjeefqfmw#wzsfp#lewkf#Lwwlnbm#Fnsjqf=?jnd#pq`>!kwws9,,Bm#Jmwqlgv`wjlm#wl`lmpfrvfm`f#le#wkfgfsbqwvqf#eqln#wkf@lmefgfqbwf#Pwbwfpjmgjdfmlvp#sflsofpSql`ffgjmdp#le#wkfjmelqnbwjlm#lm#wkfwkflqjfp#kbuf#affmjmuloufnfmw#jm#wkfgjujgfg#jmwl#wkqffbgib`fmw#`lvmwqjfpjp#qfpslmpjaof#elqgjpplovwjlm#le#wkf`loobalqbwjlm#tjwktjgfoz#qfdbqgfg#bpkjp#`lmwfnslqbqjfpelvmgjmd#nfnafq#leGlnjmj`bm#Qfsvaoj`dfmfqbooz#b``fswfgwkf#slppjajojwz#lebqf#bopl#bubjobaofvmgfq#`lmpwqv`wjlmqfpwlqbwjlm#le#wkfwkf#dfmfqbo#svaoj`jp#bonlpw#fmwjqfozsbppfp#wkqlvdk#wkfkbp#affm#pvddfpwfg`lnsvwfq#bmg#ujgflDfqnbmj`#obmdvbdfp#b``lqgjmd#wl#wkf#gjeefqfmw#eqln#wkfpklqwoz#bewfqtbqgpkqfe>!kwwsp9,,ttt-qf`fmw#gfufolsnfmwAlbqg#le#Gjqf`wlqp?gju#`obpp>!pfbq`k\u007F#?b#kqfe>!kwws9,,Jm#sbqwj`vobq/#wkfNvowjsof#ellwmlwfplq#lwkfq#pvapwbm`fwklvpbmgp#le#zfbqpwqbmpobwjlm#le#wkf?,gju=\u000E\t?,gju=\u000E\t\u000E\t?b#kqfe>!jmgf{-skstbp#fpwbaojpkfg#jmnjm-ip!=?,p`qjsw=\tsbqwj`jsbwf#jm#wkfb#pwqlmd#jmeovfm`fpwzof>!nbqdjm.wls9qfsqfpfmwfg#az#wkfdqbgvbwfg#eqln#wkfWqbgjwjlmbooz/#wkfFofnfmw+!p`qjsw!*8Kltfufq/#pjm`f#wkf,gju=\t?,gju=\t?gju#ofew8#nbqdjm.ofew9sqlwf`wjlm#bdbjmpw38#ufqwj`bo.bojdm9Vmelqwvmbwfoz/#wkfwzsf>!jnbdf,{.j`lm,gju=\t?gju#`obpp>!#`obpp>!`ofbqej{!=?gju#`obpp>!ellwfq\n\n?,gju=\t\n\n?,gju=\twkf#nlwjlm#sj`wvqf<}=f!t0-lqd,2:::,{kwno!=?b#wbqdfw>!\\aobmh!#wf{w,kwno8#`kbqpfw>!#wbqdfw>!\\aobmh!=?wbaof#`foosbggjmd>!bvwl`lnsofwf>!lee!#wf{w.bojdm9#`fmwfq8wl#obpw#ufqpjlm#az#ab`hdqlvmg.`lolq9# !#kqfe>!kwws9,,ttt-,gju=?,gju=?gju#jg>?b#kqfe>! !#`obpp>!!=?jnd#pq`>!kwws9,,`qjsw!#pq`>!kwws9,,\t?p`qjsw#obmdvbdf>!,,FM!#!kwws9,,ttt-tfm`lgfVQJ@lnslmfmw+!#kqfe>!ibubp`qjsw9?gju#`obpp>!`lmwfmwgl`vnfmw-tqjwf+$?p`slpjwjlm9#baplovwf8p`qjsw#pq`>!kwws9,,#pwzof>!nbqdjm.wls9-njm-ip!=?,p`qjsw=\t?,gju=\t?gju#`obpp>!t0-lqd,2:::,{kwno!#\t\u000E\t?,algz=\u000E\t?,kwno=gjpwjm`wjlm#afwtffm,!#wbqdfw>!\\aobmh!=?ojmh#kqfe>!kwws9,,fm`lgjmd>!vwe.;!<=\tt-bggFufmwOjpwfmfq!kwws9,,ttt-j`lm!#kqfe>!kwws9,,#pwzof>!ab`hdqlvmg9wzsf>!wf{w,`pp!#,=\tnfwb#sqlsfqwz>!ld9w?jmsvw#wzsf>!wf{w!##pwzof>!wf{w.bojdm9wkf#gfufolsnfmw#le#wzofpkffw!#wzsf>!wfkwno8#`kbqpfw>vwe.;jp#`lmpjgfqfg#wl#afwbaof#tjgwk>!233&!#Jm#bggjwjlm#wl#wkf#`lmwqjavwfg#wl#wkf#gjeefqfm`fp#afwtffmgfufolsnfmw#le#wkf#Jw#jp#jnslqwbmw#wl#?,p`qjsw=\t\t?p`qjsw##pwzof>!elmw.pjyf92=?,psbm=?psbm#jg>daOjaqbqz#le#@lmdqfpp?jnd#pq`>!kwws9,,jnFmdojpk#wqbmpobwjlmB`bgfnz#le#P`jfm`fpgju#pwzof>!gjpsobz9`lmpwqv`wjlm#le#wkf-dfwFofnfmwAzJg+jg*jm#`lmivm`wjlm#tjwkFofnfmw+$p`qjsw$*8#?nfwb#sqlsfqwz>!ld9<}=f!wf{w!#mbnf>!=Sqjub`z#Sloj`z?,b=bgnjmjpwfqfg#az#wkffmbaofPjmdofQfrvfpwpwzof>%rvlw8nbqdjm9?,gju=?,gju=?,gju=?=?jnd#pq`>!kwws9,,j#pwzof>%rvlw8eolbw9qfefqqfg#wl#bp#wkf#wlwbo#slsvobwjlm#lejm#Tbpkjmdwlm/#G-@-#pwzof>!ab`hdqlvmg.bnlmd#lwkfq#wkjmdp/lqdbmjybwjlm#le#wkfsbqwj`jsbwfg#jm#wkfwkf#jmwqlgv`wjlm#lejgfmwjejfg#tjwk#wkfej`wjlmbo#`kbqb`wfq#L{elqg#Vmjufqpjwz#njpvmgfqpwbmgjmd#leWkfqf#bqf/#kltfufq/pwzofpkffw!#kqfe>!,@lovnajb#Vmjufqpjwzf{sbmgfg#wl#jm`ovgfvpvbooz#qfefqqfg#wljmgj`bwjmd#wkbw#wkfkbuf#pvddfpwfg#wkbwbeejojbwfg#tjwk#wkf`lqqfobwjlm#afwtffmmvnafq#le#gjeefqfmw=?,wg=?,wq=?,wbaof=Qfsvaoj`#le#Jqfobmg\t?,p`qjsw=\t?p`qjsw#vmgfq#wkf#jmeovfm`f`lmwqjavwjlm#wl#wkfLeej`jbo#tfapjwf#lekfbgrvbqwfqp#le#wkf`fmwfqfg#bqlvmg#wkfjnsoj`bwjlmp#le#wkfkbuf#affm#gfufolsfgEfgfqbo#Qfsvaoj`#leaf`bnf#jm`qfbpjmdoz`lmwjmvbwjlm#le#wkfMlwf/#kltfufq/#wkbwpjnjobq#wl#wkbw#le#`bsbajojwjfp#le#wkfb``lqgbm`f#tjwk#wkfsbqwj`jsbmwp#jm#wkfevqwkfq#gfufolsnfmwvmgfq#wkf#gjqf`wjlmjp#lewfm#`lmpjgfqfgkjp#zlvmdfq#aqlwkfq?,wg=?,wq=?,wbaof=?b#kwws.frvju>![.VB.skzpj`bo#sqlsfqwjfple#Aqjwjpk#@lovnajbkbp#affm#`qjwj`jyfg+tjwk#wkf#f{`fswjlmrvfpwjlmp#balvw#wkfsbppjmd#wkqlvdk#wkf3!#`foosbggjmd>!3!#wklvpbmgp#le#sflsofqfgjqf`wp#kfqf-#Elqkbuf#`kjogqfm#vmgfq&0F&0@,p`qjsw&0F!**8?b#kqfe>!kwws9,,ttt-?oj=?b#kqfe>!kwws9,,pjwf\\mbnf!#`lmwfmw>!wf{w.gf`lqbwjlm9mlmfpwzof>!gjpsobz9#mlmf?nfwb#kwws.frvju>![.mft#Gbwf+*-dfwWjnf+*#wzsf>!jnbdf,{.j`lm!?,psbm=?psbm#`obpp>!obmdvbdf>!ibubp`qjswtjmglt-ol`bwjlm-kqfe?b#kqfe>!ibubp`qjsw9..=\u000E\t?p`qjsw#wzsf>!w?b#kqfe>$kwws9,,ttt-klqw`vw#j`lm!#kqfe>!?,gju=\u000E\t?gju#`obpp>!?p`qjsw#pq`>!kwws9,,!#qfo>!pwzofpkffw!#w?,gju=\t?p`qjsw#wzsf>,b=#?b#kqfe>!kwws9,,#booltWqbmpsbqfm`z>![.VB.@lnsbwjaof!#`lmqfobwjlmpkjs#afwtffm\t?,p`qjsw=\u000E\t?p`qjsw#?,b=?,oj=?,vo=?,gju=bppl`jbwfg#tjwk#wkf#sqldqbnnjmd#obmdvbdf?,b=?b#kqfe>!kwws9,,?,b=?,oj=?oj#`obpp>!elqn#b`wjlm>!kwws9,,?gju#pwzof>!gjpsobz9wzsf>!wf{w!#mbnf>!r!?wbaof#tjgwk>!233&!#ab`hdqlvmg.slpjwjlm9!#alqgfq>!3!#tjgwk>!qfo>!pklqw`vw#j`lm!#k5=?vo=?oj=?b#kqfe>!##?nfwb#kwws.frvju>!`pp!#nfgjb>!p`qffm!#qfpslmpjaof#elq#wkf#!#wzsf>!bssoj`bwjlm,!#pwzof>!ab`hdqlvmg.kwno8#`kbqpfw>vwe.;!#booltwqbmpsbqfm`z>!pwzofpkffw!#wzsf>!wf\u000E\t?nfwb#kwws.frvju>!=?,psbm=?psbm#`obpp>!3!#`foopsb`jmd>!3!=8\t?,p`qjsw=\t?p`qjsw#plnfwjnfp#`boofg#wkfglfp#mlw#mf`fppbqjozElq#nlqf#jmelqnbwjlmbw#wkf#afdjmmjmd#le#?\"GL@WZSF#kwno=?kwnosbqwj`vobqoz#jm#wkf#wzsf>!kjggfm!#mbnf>!ibubp`qjsw9uljg+3*8!feef`wjufmfpp#le#wkf#bvwl`lnsofwf>!lee!#dfmfqbooz#`lmpjgfqfg=?jmsvw#wzsf>!wf{w!#!=?,p`qjsw=\u000E\t?p`qjswwkqlvdklvw#wkf#tlqog`lnnlm#njp`lm`fswjlmbppl`jbwjlm#tjwk#wkf?,gju=\t?,gju=\t?gju#`gvqjmd#kjp#ojefwjnf/`lqqfpslmgjmd#wl#wkfwzsf>!jnbdf,{.j`lm!#bm#jm`qfbpjmd#mvnafqgjsolnbwj`#qfobwjlmpbqf#lewfm#`lmpjgfqfgnfwb#`kbqpfw>!vwe.;!#?jmsvw#wzsf>!wf{w!#f{bnsofp#jm`ovgf#wkf!=?jnd#pq`>!kwws9,,jsbqwj`jsbwjlm#jm#wkfwkf#fpwbaojpknfmw#le\t?,gju=\t?gju#`obpp>!%bns8maps8%bns8maps8wl#gfwfqnjmf#tkfwkfqrvjwf#gjeefqfmw#eqlnnbqhfg#wkf#afdjmmjmdgjpwbm`f#afwtffm#wkf`lmwqjavwjlmp#wl#wkf`lmeoj`w#afwtffm#wkftjgfoz#`lmpjgfqfg#wltbp#lmf#le#wkf#ejqpwtjwk#ubqzjmd#gfdqffpkbuf#psf`vobwfg#wkbw+gl`vnfmw-dfwFofnfmwsbqwj`jsbwjmd#jm#wkflqjdjmbooz#gfufolsfgfwb#`kbqpfw>!vwe.;!=#wzsf>!wf{w,`pp!#,=\tjmwfq`kbmdfbaoz#tjwknlqf#`olpfoz#qfobwfgpl`jbo#bmg#slojwj`bowkbw#tlvog#lwkfqtjpfsfqsfmgj`vobq#wl#wkfpwzof#wzsf>!wf{w,`ppwzsf>!pvanjw!#mbnf>!ebnjojfp#qfpjgjmd#jmgfufolsjmd#`lvmwqjfp`lnsvwfq#sqldqbnnjmdf`lmlnj`#gfufolsnfmwgfwfqnjmbwjlm#le#wkfelq#nlqf#jmelqnbwjlmlm#pfufqbo#l``bpjlmpslqwvdv/Fp#+Fvqlsfv*VWE.;!#pfwWjnflvw+evm`wjlm+*gjpsobz9jmojmf.aol`h8?jmsvw#wzsf>!pvanjw!#wzsf#>#$wf{w,ibubp`qj?jnd#pq`>!kwws9,,ttt-!#!kwws9,,ttt-t0-lqd,pklqw`vw#j`lm!#kqfe>!!#bvwl`lnsofwf>!lee!#?,b=?,gju=?gju#`obpp>?,b=?,oj=\t?oj#`obpp>!`pp!#wzsf>!wf{w,`pp!#?elqn#b`wjlm>!kwws9,,{w,`pp!#kqfe>!kwws9,,ojmh#qfo>!bowfqmbwf!#\u000E\t?p`qjsw#wzsf>!wf{w,#lm`oj`h>!ibubp`qjsw9+mft#Gbwf*-dfwWjnf+*~kfjdkw>!2!#tjgwk>!2!#Sflsof$p#Qfsvaoj`#le##?b#kqfe>!kwws9,,ttt-wf{w.gf`lqbwjlm9vmgfqwkf#afdjmmjmd#le#wkf#?,gju=\t?,gju=\t?,gju=\tfpwbaojpknfmw#le#wkf#?,gju=?,gju=?,gju=?,g ujftslqwxnjm.kfjdkw9\t?p`qjsw#pq`>!kwws9,,lswjlm=?lswjlm#ubovf>lewfm#qfefqqfg#wl#bp#,lswjlm=\t?lswjlm#ubov?\"GL@WZSF#kwno=\t?\"..XJmwfqmbwjlmbo#Bjqslqw=\t?b#kqfe>!kwws9,,ttt?,b=?b#kqfe>!kwws9,,t\fTL\fT^\fTE\fT^\fUh\fT{\fTN\roI\ro|\roL\ro{\roO\rov\rot\nAO\u0005Gx\bTA\nzk#+\u000BUm\u0005Gx*\fHD\fHS\fH\\\fIa\fHJ\fIk\fHZ\fHM\fHR\fHe\fHD\fH^\fIg\fHM\fHy\fIa\fH[\fIk\fHH\fIa\fH\\\fHp\fHR\fHD\fHy\fHR\fH\\\fIl\fHT\fHn\fH@\fHn\fHK\fHS\fHH\fHT\fIa\fHI\fHR\fHF\fHD\fHR\fHT\fIa\fHY\fIl\fHy\fHR\fH\\\fHT\fHn\fHT\fIa\fHy\fH\\\fHO\fHT\fHR\fHB\fH{\fIa\fH\\\fIl\fHv\fHS\fHs\fIa\fHL\fIg\fHn\fHY\fHS\fHp\fIa\fHr\fHR\fHD\fHi\fHB\fIk\fH\\\fHS\fHy\fHR\fHY\fHS\fHA\fHS\fHD\fIa\fHD\fH{\fHR\fHM\fHS\fHC\fHR\fHm\fHy\fIa\fHC\fIg\fHn\fHy\fHS\fHT\fIm\fH\\\fHy\fIa\fH[\fHR\fHF\fHU\fIm\fHm\fHv\fHH\fIl\fHF\fIa\fH\\\fH@\fHn\fHK\fHD\fHs\fHS\fHF\fIa\fHF\fHO\fIl\fHy\fIa\fH\\\fHS\fHy\fIk\fHs\fHF\fIa\fH\\\fHR\fH\\\fHn\fHA\fHF\fIa\fH\\\fHR\fHF\fIa\fHH\fHB\fHR\fH^\fHS\fHy\fIg\fHn\fH\\\fHG\fHP\fIa\fHH\fHR\fH\\\fHD\fHS\fH\\\fIa\fHB\fHR\fHO\fH^\fHS\fHB\fHS\fHs\fIk\fHMgfp`qjswjlm!#`lmwfmw>!gl`vnfmw-ol`bwjlm-sqlw-dfwFofnfmwpAzWbdMbnf+?\"GL@WZSF#kwno=\t?kwno#?nfwb#`kbqpfw>!vwe.;!=9vqo!#`lmwfmw>!kwws9,,-`pp!#qfo>!pwzofpkffw!pwzof#wzsf>!wf{w,`pp!=wzsf>!wf{w,`pp!#kqfe>!t0-lqd,2:::,{kwno!#{nowzsf>!wf{w,ibubp`qjsw!#nfwklg>!dfw!#b`wjlm>!ojmh#qfo>!pwzofpkffw!##>#gl`vnfmw-dfwFofnfmwwzsf>!jnbdf,{.j`lm!#,=`foosbggjmd>!3!#`foops-`pp!#wzsf>!wf{w,`pp!#?,b=?,oj=?oj=?b#kqfe>!!#tjgwk>!2!#kfjdkw>!2!!=?b#kqfe>!kwws9,,ttt-pwzof>!gjpsobz9mlmf8!=bowfqmbwf!#wzsf>!bssoj.,,T0@,,GWG#[KWNO#2-3#foopsb`jmd>!3!#`foosbg#wzsf>!kjggfm!#ubovf>!,b=%maps8?psbm#qlof>!p\t?jmsvw#wzsf>!kjggfm!#obmdvbdf>!IbubP`qjsw!##gl`vnfmw-dfwFofnfmwpAd>!3!#`foopsb`jmd>!3!#zsf>!wf{w,`pp!#nfgjb>!wzsf>$wf{w,ibubp`qjsw$tjwk#wkf#f{`fswjlm#le#zsf>!wf{w,`pp!#qfo>!pw#kfjdkw>!2!#tjgwk>!2!#>$(fm`lgfVQJ@lnslmfmw+?ojmh#qfo>!bowfqmbwf!#\talgz/#wq/#jmsvw/#wf{wnfwb#mbnf>!qlalwp!#`lmnfwklg>!slpw!#b`wjlm>!=\t?b#kqfe>!kwws9,,ttt-`pp!#qfo>!pwzofpkffw!#?,gju=?,gju=?gju#`obppobmdvbdf>!ibubp`qjsw!=bqjb.kjggfm>!wqvf!=.[?qjsw!#wzsf>!wf{w,ibubpo>38~*+*8\t+evm`wjlm+*xab`hdqlvmg.jnbdf9#vqo+,b=?,oj=?oj=?b#kqfe>!k\n\n?oj=?b#kqfe>!kwws9,,bwlq!#bqjb.kjggfm>!wqv=#?b#kqfe>!kwws9,,ttt-obmdvbdf>!ibubp`qjsw!#,lswjlm=\t?lswjlm#ubovf,gju=?,gju=?gju#`obpp>qbwlq!#bqjb.kjggfm>!wqf>+mft#Gbwf*-dfwWjnf+*slqwvdv/Fp#+gl#Aqbpjo*!wf{w,?nfwb#kwws.frvju>!@lmwfqbmpjwjlmbo,,FM!#!kwws9?kwno#{nomp>!kwws9,,ttt.,,T0@,,GWG#[KWNO#2-3#WGWG,{kwno2.wqbmpjwjlmbo,,ttt-t0-lqd,WQ,{kwno2,sf#>#$wf{w,ibubp`qjsw$8?nfwb#mbnf>!gfp`qjswjlmsbqfmwMlgf-jmpfqwAfelqf?jmsvw#wzsf>!kjggfm!#mbip!#wzsf>!wf{w,ibubp`qj+gl`vnfmw*-qfbgz+evm`wjp`qjsw#wzsf>!wf{w,ibubpjnbdf!#`lmwfmw>!kwws9,,VB.@lnsbwjaof!#`lmwfmw>wno8#`kbqpfw>vwe.;!#,=\tojmh#qfo>!pklqw`vw#j`lm?ojmh#qfo>!pwzofpkffw!#?,p`qjsw=\t?p`qjsw#wzsf>>#gl`vnfmw-`qfbwfFofnfm?b#wbqdfw>!\\aobmh!#kqfe>#gl`vnfmw-dfwFofnfmwpAjmsvw#wzsf>!wf{w!#mbnf>b-wzsf#>#$wf{w,ibubp`qjmsvw#wzsf>!kjggfm!#mbnfkwno8#`kbqpfw>vwe.;!#,=gwg!=\t?kwno#{nomp>!kwws.,,T0@,,GWG#KWNO#7-32#WfmwpAzWbdMbnf+$p`qjsw$*jmsvw#wzsf>!kjggfm!#mbn?p`qjsw#wzsf>!wf{w,ibubp!#pwzof>!gjpsobz9mlmf8!=gl`vnfmw-dfwFofnfmwAzJg+>gl`vnfmw-`qfbwfFofnfmw+$#wzsf>$wf{w,ibubp`qjsw$jmsvw#wzsf>!wf{w!#mbnf>!g-dfwFofnfmwpAzWbdMbnf+pmj`bo!#kqfe>!kwws9,,ttt-@,,GWG#KWNO#7-32#Wqbmpjw?pwzof#wzsf>!wf{w,`pp!=\t\t?pwzof#wzsf>!wf{w,`pp!=jlmbo-gwg!=\t?kwno#{nomp>kwws.frvju>!@lmwfmw.Wzsfgjmd>!3!#`foopsb`jmd>!3!kwno8#`kbqpfw>vwe.;!#,=\t#pwzof>!gjpsobz9mlmf8!=??oj=?b#kqfe>!kwws9,,ttt-#wzsf>$wf{w,ibubp`qjsw$= reverse(key, len) is the bit-wise reversal of the len least significant bits of key. + */ + private static int getNextKey(int key, int len) { + int step = 1 << (len - 1); + while ((key & step) != 0) { + step >>= 1; + } + return (key & (step - 1)) + step; + } + + /** + * Stores {@code item} in {@code table[0], table[step], table[2 * step] .., table[end]}. + * + *

Assumes that end is an integer multiple of step. + */ + private static void replicateValue(int[] table, int offset, int step, int end, int item) { + do { + end -= step; + table[offset + end] = item; + } while (end > 0); + } + + /** + * @param count histogram of bit lengths for the remaining symbols, + * @param len code length of the next processed symbol. + * @return table width of the next 2nd level table. + */ + private static int nextTableBitSize(int[] count, int len, int rootBits) { + int left = 1 << (len - rootBits); + while (len < MAX_LENGTH) { + left -= count[len]; + if (left <= 0) { + break; + } + len++; + left <<= 1; + } + return len - rootBits; + } + + /** + * Builds Huffman lookup table assuming code lengths are in symbol order. + * + * @return number of slots used by resulting Huffman table + */ + static int buildHuffmanTable(int[] tableGroup, int tableIdx, int rootBits, int[] codeLengths, + int codeLengthsSize) { + final int tableOffset = tableGroup[tableIdx]; + int key; // Reversed prefix code. + final int[] sorted = new int[codeLengthsSize]; // Symbols sorted by code length. + // TODO(eustas): fill with zeroes? + final int[] count = new int[MAX_LENGTH + 1]; // Number of codes of each length. + final int[] offset = new int[MAX_LENGTH + 1]; // Offsets in sorted table for each length. + int symbol; + + // Build histogram of code lengths. + for (symbol = 0; symbol < codeLengthsSize; symbol++) { + count[codeLengths[symbol]]++; + } + + // Generate offsets into sorted symbol table by code length. + offset[1] = 0; + for (int len = 1; len < MAX_LENGTH; len++) { + offset[len + 1] = offset[len] + count[len]; + } + + // Sort symbols by length, by symbol order within each length. + for (symbol = 0; symbol < codeLengthsSize; symbol++) { + if (codeLengths[symbol] != 0) { + sorted[offset[codeLengths[symbol]]++] = symbol; + } + } + + int tableBits = rootBits; + int tableSize = 1 << tableBits; + int totalSize = tableSize; + + // Special case code with only one value. + if (offset[MAX_LENGTH] == 1) { + for (key = 0; key < totalSize; key++) { + tableGroup[tableOffset + key] = sorted[0]; + } + return totalSize; + } + + // Fill in root table. + key = 0; + symbol = 0; + for (int len = 1, step = 2; len <= rootBits; len++, step <<= 1) { + for (; count[len] > 0; count[len]--) { + replicateValue(tableGroup, tableOffset + key, step, tableSize, + len << 16 | sorted[symbol++]); + key = getNextKey(key, len); + } + } + + // Fill in 2nd level tables and add pointers to root table. + final int mask = totalSize - 1; + int low = -1; + int currentOffset = tableOffset; + for (int len = rootBits + 1, step = 2; len <= MAX_LENGTH; len++, step <<= 1) { + for (; count[len] > 0; count[len]--) { + if ((key & mask) != low) { + currentOffset += tableSize; + tableBits = nextTableBitSize(count, len, rootBits); + tableSize = 1 << tableBits; + totalSize += tableSize; + low = key & mask; + tableGroup[tableOffset + low] = + (tableBits + rootBits) << 16 | (currentOffset - tableOffset - low); + } + replicateValue(tableGroup, currentOffset + (key >> rootBits), step, tableSize, + (len - rootBits) << 16 | sorted[symbol++]); + key = getNextKey(key, len); + } + } + return totalSize; + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/State.java b/firka/android/app/src/main/java/org/brotli/dec/State.java new file mode 100644 index 00000000..94db93ab --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/State.java @@ -0,0 +1,100 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +import java.io.InputStream; + +final class State { + byte[] ringBuffer; + byte[] contextModes; + byte[] contextMap; + byte[] distContextMap; + byte[] distExtraBits; + byte[] output; + byte[] byteBuffer; // BitReader + + short[] shortBuffer; // BitReader + + int[] intBuffer; // BitReader + int[] rings; + int[] blockTrees; + int[] literalTreeGroup; + int[] commandTreeGroup; + int[] distanceTreeGroup; + int[] distOffset; + + long accumulator64; // BitReader: pre-fetched bits. + + int runningState; // Default value is 0 == Decode.UNINITIALIZED + int nextRunningState; + int accumulator32; // BitReader: pre-fetched bits. + int bitOffset; // BitReader: bit-reading position in accumulator. + int halfOffset; // BitReader: offset of next item in intBuffer/shortBuffer. + int tailBytes; // BitReader: number of bytes in unfinished half. + int endOfStreamReached; // BitReader: input stream is finished. + int metaBlockLength; + int inputEnd; + int isUncompressed; + int isMetadata; + int literalBlockLength; + int numLiteralBlockTypes; + int commandBlockLength; + int numCommandBlockTypes; + int distanceBlockLength; + int numDistanceBlockTypes; + int pos; + int maxDistance; + int distRbIdx; + int trivialLiteralContext; + int literalTreeIdx; + int commandTreeIdx; + int j; + int insertLength; + int contextMapSlice; + int distContextMapSlice; + int contextLookupOffset1; + int contextLookupOffset2; + int distanceCode; + int numDirectDistanceCodes; + int distancePostfixBits; + int distance; + int copyLength; + int maxBackwardDistance; + int maxRingBufferSize; + int ringBufferSize; + int expectedTotalSize; + int outputOffset; + int outputLength; + int outputUsed; + int ringBufferBytesWritten; + int ringBufferBytesReady; + int isEager; + int isLargeWindow; + + // Compound dictionary + int cdNumChunks; + int cdTotalSize; + int cdBrIndex; + int cdBrOffset; + int cdBrLength; + int cdBrCopied; + byte[][] cdChunks; + int[] cdChunkOffsets; + int cdBlockBits; + byte[] cdBlockMap; + + InputStream /* @Nullable */ input; // BitReader + + State() { + this.ringBuffer = new byte[0]; + this.rings = new int[10]; + this.rings[0] = 16; + this.rings[1] = 15; + this.rings[2] = 11; + this.rings[3] = 4; + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/Transform.java b/firka/android/app/src/main/java/org/brotli/dec/Transform.java new file mode 100644 index 00000000..6a57a9ec --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/Transform.java @@ -0,0 +1,236 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +import java.nio.ByteBuffer; + +/** + * Transformations on dictionary words. + * + * Transform descriptor is a triplet: {prefix, operator, suffix}. + * "prefix" and "suffix" are short strings inserted before and after transformed dictionary word. + * "operator" is applied to dictionary word itself. + * + * Some operators has "built-in" parameters, i.e. parameter is defined by operator ordinal. Other + * operators have "external" parameters, supplied via additional table encoded in shared dictionary. + * + * Operators: + * - IDENTITY (0): dictionary word is inserted "as is" + * - OMIT_LAST_N (1 - 9): last N octets of dictionary word are not inserted; N == ordinal + * - OMIT_FIRST_M (12-20): first M octets of dictionary word are not inserted; M == ordinal - 11 + * - UPPERCASE_FIRST (10): first "scalar" is XOR'ed with number 32 + * - UPPERCASE_ALL (11): all "scalars" are XOR'ed with number 32 + * - SHIFT_FIRST (21): first "scalar" is shifted by number form parameter table + * - SHIFT_ALL (22): all "scalar" is shifted by number form parameter table + * + * Here "scalar" is a variable length character coding similar to UTF-8 encoding. + * UPPERCASE_XXX / SHIFT_XXX operators were designed to change the case of UTF-8 encoded characters. + * While UPPERCASE_XXX works well only on ASCII charset, SHIFT is much more generic and could be + * used for most (all?) alphabets. + */ +final class Transform { + + static final class Transforms { + final int numTransforms; + final int[] triplets; + final byte[] prefixSuffixStorage; + final int[] prefixSuffixHeads; + final short[] params; + + Transforms(int numTransforms, int prefixSuffixLen, int prefixSuffixCount) { + this.numTransforms = numTransforms; + this.triplets = new int[numTransforms * 3]; + this.params = new short[numTransforms]; + this.prefixSuffixStorage = new byte[prefixSuffixLen]; + this.prefixSuffixHeads = new int[prefixSuffixCount + 1]; + } + } + + static final int NUM_RFC_TRANSFORMS = 121; + static final Transforms RFC_TRANSFORMS = new Transforms(NUM_RFC_TRANSFORMS, 167, 50); + + private static final int OMIT_FIRST_LAST_LIMIT = 9; + + private static final int IDENTITY = 0; + private static final int OMIT_LAST_BASE = IDENTITY + 1 - 1; // there is no OMIT_LAST_0. + private static final int UPPERCASE_FIRST = OMIT_LAST_BASE + OMIT_FIRST_LAST_LIMIT + 1; + private static final int UPPERCASE_ALL = UPPERCASE_FIRST + 1; + private static final int OMIT_FIRST_BASE = UPPERCASE_ALL + 1 - 1; // there is no OMIT_FIRST_0. + private static final int SHIFT_FIRST = OMIT_FIRST_BASE + OMIT_FIRST_LAST_LIMIT + 1; + private static final int SHIFT_ALL = SHIFT_FIRST + 1; + + // Bundle of 0-terminated strings. + private static final String PREFIX_SUFFIX_SRC = "# #s #, #e #.# the #.com/#\u00C2\u00A0# of # and" + + " # in # to #\"#\">#\n#]# for # a # that #. # with #'# from # by #. The # on # as # is #ing" + + " #\n\t#:#ed #(# at #ly #=\"# of the #. This #,# not #er #al #='#ful #ive #less #est #ize #" + + "ous #"; + private static final String TRANSFORMS_SRC = " !! ! , *! &! \" ! ) * * - ! # ! #!*! " + + "+ ,$ ! - % . / # 0 1 . \" 2 3!* 4% ! # / 5 6 7 8 0 1 & $ 9 + : " + + " ; < ' != > ?! 4 @ 4 2 & A *# ( B C& ) % ) !*# *-% A +! *. D! %' & E *6 F " + + " G% ! *A *% H! D I!+! J!+ K +- *4! A L!*4 M N +6 O!*% +.! K *G P +%( ! G *D +D " + + " Q +# *K!*G!+D!+# +G +A +4!+% +K!+4!*D!+K!*K"; + + private static void unpackTransforms(byte[] prefixSuffix, + int[] prefixSuffixHeads, int[] transforms, String prefixSuffixSrc, String transformsSrc) { + final int n = prefixSuffixSrc.length(); + int index = 1; + int j = 0; + for (int i = 0; i < n; ++i) { + final char c = prefixSuffixSrc.charAt(i); + if (c == 35) { // == # + prefixSuffixHeads[index++] = j; + } else { + prefixSuffix[j++] = (byte) c; + } + } + + for (int i = 0; i < NUM_RFC_TRANSFORMS * 3; ++i) { + transforms[i] = transformsSrc.charAt(i) - 32; + } + } + + static { + unpackTransforms(RFC_TRANSFORMS.prefixSuffixStorage, RFC_TRANSFORMS.prefixSuffixHeads, + RFC_TRANSFORMS.triplets, PREFIX_SUFFIX_SRC, TRANSFORMS_SRC); + } + + static int transformDictionaryWord(byte[] dst, int dstOffset, ByteBuffer src, int srcOffset, + int len, Transforms transforms, int transformIndex) { + int offset = dstOffset; + final int[] triplets = transforms.triplets; + final byte[] prefixSuffixStorage = transforms.prefixSuffixStorage; + final int[] prefixSuffixHeads = transforms.prefixSuffixHeads; + final int transformOffset = 3 * transformIndex; + final int prefixIdx = triplets[transformOffset]; + final int transformType = triplets[transformOffset + 1]; + final int suffixIdx = triplets[transformOffset + 2]; + int prefix = prefixSuffixHeads[prefixIdx]; + final int prefixEnd = prefixSuffixHeads[prefixIdx + 1]; + int suffix = prefixSuffixHeads[suffixIdx]; + final int suffixEnd = prefixSuffixHeads[suffixIdx + 1]; + + int omitFirst = transformType - OMIT_FIRST_BASE; + int omitLast = transformType - OMIT_LAST_BASE; + if (omitFirst < 1 || omitFirst > OMIT_FIRST_LAST_LIMIT) { + omitFirst = 0; + } + if (omitLast < 1 || omitLast > OMIT_FIRST_LAST_LIMIT) { + omitLast = 0; + } + + // Copy prefix. + while (prefix != prefixEnd) { + dst[offset++] = prefixSuffixStorage[prefix++]; + } + + // Copy trimmed word. + if (omitFirst > len) { + omitFirst = len; + } + srcOffset += omitFirst; + len -= omitFirst; + len -= omitLast; + int i = len; + while (i > 0) { + dst[offset++] = src.get(srcOffset++); + i--; + } + + // Ferment. + if (transformType == UPPERCASE_FIRST || transformType == UPPERCASE_ALL) { + int uppercaseOffset = offset - len; + if (transformType == UPPERCASE_FIRST) { + len = 1; + } + while (len > 0) { + final int c0 = dst[uppercaseOffset] & 0xFF; + if (c0 < 0xC0) { + if (c0 >= 97 && c0 <= 122) { // in [a..z] range + dst[uppercaseOffset] ^= (byte) 32; + } + uppercaseOffset += 1; + len -= 1; + } else if (c0 < 0xE0) { + dst[uppercaseOffset + 1] ^= (byte) 32; + uppercaseOffset += 2; + len -= 2; + } else { + dst[uppercaseOffset + 2] ^= (byte) 5; + uppercaseOffset += 3; + len -= 3; + } + } + } else if (transformType == SHIFT_FIRST || transformType == SHIFT_ALL) { + int shiftOffset = offset - len; + final short param = transforms.params[transformIndex]; + /* Limited sign extension: scalar < (1 << 24). */ + int scalar = (param & 0x7FFF) + (0x1000000 - (param & 0x8000)); + while (len > 0) { + int step = 1; + final int c0 = dst[shiftOffset] & 0xFF; + if (c0 < 0x80) { + /* 1-byte rune / 0sssssss / 7 bit scalar (ASCII). */ + scalar += c0; + dst[shiftOffset] = (byte) (scalar & 0x7F); + } else if (c0 < 0xC0) { + /* Continuation / 10AAAAAA. */ + } else if (c0 < 0xE0) { + /* 2-byte rune / 110sssss AAssssss / 11 bit scalar. */ + if (len >= 2) { + final byte c1 = dst[shiftOffset + 1]; + scalar += (c1 & 0x3F) | ((c0 & 0x1F) << 6); + dst[shiftOffset] = (byte) (0xC0 | ((scalar >> 6) & 0x1F)); + dst[shiftOffset + 1] = (byte) ((c1 & 0xC0) | (scalar & 0x3F)); + step = 2; + } else { + step = len; + } + } else if (c0 < 0xF0) { + /* 3-byte rune / 1110ssss AAssssss BBssssss / 16 bit scalar. */ + if (len >= 3) { + final byte c1 = dst[shiftOffset + 1]; + final byte c2 = dst[shiftOffset + 2]; + scalar += (c2 & 0x3F) | ((c1 & 0x3F) << 6) | ((c0 & 0x0F) << 12); + dst[shiftOffset] = (byte) (0xE0 | ((scalar >> 12) & 0x0F)); + dst[shiftOffset + 1] = (byte) ((c1 & 0xC0) | ((scalar >> 6) & 0x3F)); + dst[shiftOffset + 2] = (byte) ((c2 & 0xC0) | (scalar & 0x3F)); + step = 3; + } else { + step = len; + } + } else if (c0 < 0xF8) { + /* 4-byte rune / 11110sss AAssssss BBssssss CCssssss / 21 bit scalar. */ + if (len >= 4) { + final byte c1 = dst[shiftOffset + 1]; + final byte c2 = dst[shiftOffset + 2]; + final byte c3 = dst[shiftOffset + 3]; + scalar += (c3 & 0x3F) | ((c2 & 0x3F) << 6) | ((c1 & 0x3F) << 12) | ((c0 & 0x07) << 18); + dst[shiftOffset] = (byte) (0xF0 | ((scalar >> 18) & 0x07)); + dst[shiftOffset + 1] = (byte) ((c1 & 0xC0) | ((scalar >> 12) & 0x3F)); + dst[shiftOffset + 2] = (byte) ((c2 & 0xC0) | ((scalar >> 6) & 0x3F)); + dst[shiftOffset + 3] = (byte) ((c3 & 0xC0) | (scalar & 0x3F)); + step = 4; + } else { + step = len; + } + } + shiftOffset += step; + len -= step; + if (transformType == SHIFT_FIRST) { + len = 0; + } + } + } + + // Copy suffix. + while (suffix != suffixEnd) { + dst[offset++] = prefixSuffixStorage[suffix++]; + } + + return offset - dstOffset; + } +} diff --git a/firka/android/app/src/main/java/org/brotli/dec/Utils.java b/firka/android/app/src/main/java/org/brotli/dec/Utils.java new file mode 100644 index 00000000..cc4a9f0d --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/dec/Utils.java @@ -0,0 +1,119 @@ +/* Copyright 2015 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.dec; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.nio.Buffer; +import java.nio.ByteBuffer; + +/** + * A set of utility methods. + */ +final class Utils { + + private static final byte[] BYTE_ZEROES = new byte[1024]; + + private static final int[] INT_ZEROES = new int[1024]; + + /** + * Fills byte array with zeroes. + * + *

Current implementation uses {@link System#arraycopy}, so it should be used for length not + * less than 16. + * + * @param dest array to fill with zeroes + * @param offset the first byte to fill + * @param length number of bytes to change + */ + static void fillBytesWithZeroes(byte[] dest, int start, int end) { + int cursor = start; + while (cursor < end) { + int step = Math.min(cursor + 1024, end) - cursor; + System.arraycopy(BYTE_ZEROES, 0, dest, cursor, step); + cursor += step; + } + } + + /** + * Fills int array with zeroes. + * + *

Current implementation uses {@link System#arraycopy}, so it should be used for length not + * less than 16. + * + * @param dest array to fill with zeroes + * @param offset the first item to fill + * @param length number of item to change + */ + static void fillIntsWithZeroes(int[] dest, int start, int end) { + int cursor = start; + while (cursor < end) { + int step = Math.min(cursor + 1024, end) - cursor; + System.arraycopy(INT_ZEROES, 0, dest, cursor, step); + cursor += step; + } + } + + static void copyBytes(byte[] dst, int target, byte[] src, int start, int end) { + System.arraycopy(src, start, dst, target, end - start); + } + + static void copyBytesWithin(byte[] bytes, int target, int start, int end) { + System.arraycopy(bytes, start, bytes, target, end - start); + } + + static int readInput(InputStream src, byte[] dst, int offset, int length) { + try { + return src.read(dst, offset, length); + } catch (IOException e) { + throw new BrotliRuntimeException("Failed to read input", e); + } + } + + static void closeInput(InputStream src) throws IOException { + src.close(); + } + + static byte[] toUsAsciiBytes(String src) { + try { + // NB: String#getBytes(String) is present in JDK 1.1, while other variants require JDK 1.6 and + // above. + return src.getBytes("US-ASCII"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); // cannot happen + } + } + + static ByteBuffer asReadOnlyBuffer(ByteBuffer src) { + return src.asReadOnlyBuffer(); + } + + static int isReadOnly(ByteBuffer src) { + return src.isReadOnly() ? 1 : 0; + } + + static int isDirect(ByteBuffer src) { + return src.isDirect() ? 1 : 0; + } + + // Crazy pills factory: code compiled for JDK8 does not work on JRE9. + static void flipBuffer(Buffer buffer) { + buffer.flip(); + } + + static int isDebugMode() { + boolean assertsEnabled = Boolean.parseBoolean(System.getProperty("BROTLI_ENABLE_ASSERTS")); + return assertsEnabled ? 1 : 0; + } + + // See BitReader.LOG_BITNESS + static int getLogBintness() { + boolean isLongExpensive = Boolean.parseBoolean(System.getProperty("BROTLI_32_BIT_CPU")); + return isLongExpensive ? 5 : 6; + } +} diff --git a/firka/android/app/src/main/java/org/brotli/enc/PreparedDictionary.java b/firka/android/app/src/main/java/org/brotli/enc/PreparedDictionary.java new file mode 100644 index 00000000..51978015 --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/enc/PreparedDictionary.java @@ -0,0 +1,16 @@ +/* Copyright 2018 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.enc; + +import java.nio.ByteBuffer; + +/** + * Prepared dictionary data provider. + */ +public interface PreparedDictionary { + ByteBuffer getData(); +} diff --git a/firka/android/app/src/main/java/org/brotli/enc/PreparedDictionaryGenerator.java b/firka/android/app/src/main/java/org/brotli/enc/PreparedDictionaryGenerator.java new file mode 100644 index 00000000..3813429c --- /dev/null +++ b/firka/android/app/src/main/java/org/brotli/enc/PreparedDictionaryGenerator.java @@ -0,0 +1,185 @@ +/* Copyright 2017 Google Inc. All Rights Reserved. + + Distributed under MIT license. + See file LICENSE for detail or copy at https://opensource.org/licenses/MIT +*/ + +package org.brotli.enc; + +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.IntBuffer; +import java.nio.ShortBuffer; + +/** + * Java prepared (raw) dictionary producer. + */ +public class PreparedDictionaryGenerator { + + private static final int MAGIC = 0xDEBCEDE0; + private static final long HASH_MULTIPLIER = 0x1fe35a7bd3579bd3L; + + private static class PreparedDictionaryImpl implements PreparedDictionary { + private final ByteBuffer data; + + private PreparedDictionaryImpl(ByteBuffer data) { + this.data = data; + } + + @Override + public ByteBuffer getData() { + return data; + } + } + + // Disallow instantiation. + private PreparedDictionaryGenerator() { } + + public static PreparedDictionary generate(ByteBuffer src) { + return generate(src, 17, 3, 40, 5); + } + + public static PreparedDictionary generate(ByteBuffer src, + int bucketBits, int slotBits, int hashBits, int blockBits) { + ((Buffer) src).clear(); // Just in case... + if (blockBits > 12) { + throw new IllegalArgumentException("blockBits is too big"); + } + if (bucketBits >= 24) { + throw new IllegalArgumentException("bucketBits is too big"); + } + if (bucketBits - slotBits >= 16) { + throw new IllegalArgumentException("slotBits is too small"); + } + int bucketLimit = 1 << blockBits; + int numBuckets = 1 << bucketBits; + int numSlots = 1 << slotBits; + int slotMask = numSlots - 1; + int hashShift = 64 - bucketBits; + long hashMask = (~0L) >>> (64 - hashBits); + int sourceSize = src.capacity(); + if (sourceSize < 8) { + throw new IllegalArgumentException("src is too short"); + } + + /* Step 1: create "bloated" hasher. */ + short[] num = new short[numBuckets]; + int[] bucketHeads = new int[numBuckets]; + int[] nextBucket = new int[sourceSize]; + + long accumulator = 0; + for (int i = 0; i < 7; ++i) { + accumulator |= (src.get(i) & 0xFFL) << (8 * i); + } + accumulator <<= 8; + /* TODO(eustas): apply custom "store" order. */ + for (int i = 0; i + 7 < sourceSize; ++i) { + accumulator = (accumulator >>> 8) | ((src.get(i + 7) & 0xFFL) << 56); + long h = (accumulator & hashMask) * HASH_MULTIPLIER; + int key = (int) (h >>> hashShift); + int count = num[key]; + nextBucket[i] = (count == 0) ? -1 : bucketHeads[key]; + bucketHeads[key] = i; + count++; + if (count > bucketLimit) { + count = bucketLimit; + } + num[key] = (short) count; + } + + /* Step 2: find slot limits. */ + int[] slotLimit = new int[numSlots]; + int[] slotSize = new int[numSlots]; + int totalItems = 0; + for (int i = 0; i < numSlots; ++i) { + boolean overflow = false; + slotLimit[i] = bucketLimit; + while (true) { + overflow = false; + int limit = slotLimit[i]; + int count = 0; + for (int j = i; j < numBuckets; j += numSlots) { + int size = num[j]; + /* Last chain may span behind 64K limit; overflow happens only if + we are about to use 0xFFFF+ as item offset. */ + if (count >= 0xFFFF) { + overflow = true; + break; + } + if (size > limit) { + size = limit; + } + count += size; + } + if (!overflow) { + slotSize[i] = count; + totalItems += count; + break; + } + slotLimit[i]--; + } + } + + /* Step 3: transfer data to "slim" hasher. */ + int part0 = 6 * 4; + int part1 = numSlots * 4; + int part2 = numBuckets * 2; + int part3 = totalItems * 4; + int allocSize = part0 + part1 + part2 + part3 + sourceSize; + ByteBuffer flat = ByteBuffer.allocateDirect(allocSize); + ByteBuffer pointer = flat.slice(); + pointer.order(ByteOrder.nativeOrder()); + + IntBuffer struct = pointer.asIntBuffer(); + pointer.position(pointer.position() + part0); + IntBuffer slotOffsets = pointer.asIntBuffer(); + pointer.position(pointer.position() + part1); + ShortBuffer heads = pointer.asShortBuffer(); + pointer.position(pointer.position() + part2); + IntBuffer items = pointer.asIntBuffer(); + pointer.position(pointer.position() + part3); + ByteBuffer sourceCopy = pointer.slice(); + + /* magic */ struct.put(0, MAGIC); + /* source_offset */ struct.put(1, totalItems); + /* source_size */ struct.put(2, sourceSize); + /* hash_bits */ struct.put(3, hashBits); + /* bucket_bits */ struct.put(4, bucketBits); + /* slot_bits */ struct.put(5, slotBits); + + totalItems = 0; + for (int i = 0; i < numSlots; ++i) { + slotOffsets.put(i, totalItems); + totalItems += slotSize[i]; + slotSize[i] = 0; + } + + for (int i = 0; i < numBuckets; ++i) { + int slot = i & slotMask; + int count = num[i]; + if (count > slotLimit[slot]) { + count = slotLimit[slot]; + } + if (count == 0) { + heads.put(i, (short) 0xFFFF); + continue; + } + int cursor = slotSize[slot]; + heads.put(i, (short) cursor); + cursor += slotOffsets.get(slot); + slotSize[slot] += count; + int pos = bucketHeads[i]; + for (int j = 0; j < count; j++) { + items.put(cursor++, pos); + pos = nextBucket[pos]; + } + cursor--; + items.put(cursor, items.get(cursor) | 0x80000000); + } + + sourceCopy.put(src); + + return new PreparedDictionaryImpl(flat); + } +} diff --git a/firka/android/app/src/main/kotlin/app/firka/naplo/AppMain.kt b/firka/android/app/src/main/kotlin/app/firka/naplo/AppMain.kt new file mode 100644 index 00000000..6a4ad1b1 --- /dev/null +++ b/firka/android/app/src/main/kotlin/app/firka/naplo/AppMain.kt @@ -0,0 +1,88 @@ +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() + + 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() + + entry.name.endsWith("$abi/index.so") + zip.close() + nativesApkN = ZipFile(apk) + break + } + + zip.close() + } + + 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) + } + } + +} \ No newline at end of file diff --git a/firka/android/app/src/main/kotlin/app/firka/naplo/MainActivity.kt b/firka/android/app/src/main/kotlin/app/firka/naplo/MainActivity.kt new file mode 100644 index 00000000..38e39fa0 --- /dev/null +++ b/firka/android/app/src/main/kotlin/app/firka/naplo/MainActivity.kt @@ -0,0 +1,89 @@ +package app.firka.naplo + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.core.app.NotificationCompat +import androidx.wear.ongoing.OngoingActivity +import androidx.wear.ongoing.Status +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel + + +class MainActivity : FlutterActivity() { + + private val channel = "firka.app/main" + private val channelId = "ongoing_activity" + private val notificationId = 1000 + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + + notificationManager.createNotificationChannel( + NotificationChannel( + channelId, + "Ongoing Activity", + NotificationManager.IMPORTANCE_DEFAULT + ) + ) + + val notificationBuilder = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.ic_notification) + .setOngoing(true) + + val ongoingActivityStatus = Status.Builder() + // Sets the text used across various surfaces. + .addTemplate("Firka") + .build() + + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)!! + val activityPendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val ongoingActivity = OngoingActivity.Builder(applicationContext, + notificationId, notificationBuilder) + .setStaticIcon(R.drawable.ic_notification) + .setTouchIntent(activityPendingIntent) + .setStatus(ongoingActivityStatus) + .build() + + ongoingActivity.apply(applicationContext) + + + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel).setMethodCallHandler { + call, result -> + when (call.method) { + "isWear" -> { + result.success(packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH)) + } + "activity_update" -> { + notificationManager.notify(notificationId, notificationBuilder.build()) + result.success(null) + } + "activity_cancel" -> { + notificationManager.cancel(notificationId) + result.success(null) + } + else -> { + result.notImplemented() + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE) + } + +} diff --git a/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png new file mode 100644 index 00000000..a4b37589 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ diff --git a/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ede3642c Binary files /dev/null and b/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..31e7cce7 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ diff --git a/firka/android/app/src/main/res/drawable-hdpi/ic_notification.png b/firka/android/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 00000000..d54bc79d Binary files /dev/null and b/firka/android/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png new file mode 100644 index 00000000..dd8c6a87 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ diff --git a/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..835a3af5 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..a6cba6b6 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/firka/android/app/src/main/res/drawable-mdpi/ic_notification.png b/firka/android/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 00000000..11447b50 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/firka/android/app/src/main/res/drawable-v21/launch_background.xml b/firka/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/firka/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png new file mode 100644 index 00000000..8732a1f8 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ diff --git a/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..780a3748 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..3caae042 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/firka/android/app/src/main/res/drawable-xhdpi/ic_notification.png b/firka/android/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 00000000..519466c0 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..7073561b Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ diff --git a/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..74996678 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..d02cfe24 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ diff --git a/firka/android/app/src/main/res/drawable-xxhdpi/ic_notification.png b/firka/android/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 00000000..785b2a59 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png new file mode 100644 index 00000000..ed9d77e7 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ diff --git a/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..92add98e Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 00000000..c9ca9ce2 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/firka/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png b/firka/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png new file mode 100644 index 00000000..abe6efe0 Binary files /dev/null and b/firka/android/app/src/main/res/drawable-xxxhdpi/ic_notification.png differ diff --git a/firka/android/app/src/main/res/drawable/launch_background.xml b/firka/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/firka/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/firka/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml b/firka/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml new file mode 100644 index 00000000..407dab1e --- /dev/null +++ b/firka/android/app/src/main/res/mipmap-anydpi-v26/launcher_icon.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/firka/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/firka/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/firka/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/firka/android/app/src/main/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 00000000..630f7d94 Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-hdpi/launcher_icon.png differ diff --git a/firka/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/firka/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/firka/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/firka/android/app/src/main/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 00000000..c7c3b955 Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-mdpi/launcher_icon.png differ diff --git a/firka/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/firka/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/firka/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/firka/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 00000000..f55a4d5e Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/firka/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/firka/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/firka/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/firka/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 00000000..a8161896 Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/firka/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/firka/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/firka/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/firka/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 00000000..86054c63 Binary files /dev/null and b/firka/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/firka/android/app/src/main/res/values-night/styles.xml b/firka/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/firka/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/firka/android/app/src/main/res/values/styles.xml b/firka/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/firka/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/firka/android/app/src/profile/AndroidManifest.xml b/firka/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/firka/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/firka/android/app/src/release/AndroidManifest.xml b/firka/android/app/src/release/AndroidManifest.xml new file mode 100644 index 00000000..e0eea99f --- /dev/null +++ b/firka/android/app/src/release/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/firka/android/build.gradle.kts b/firka/android/build.gradle.kts new file mode 100644 index 00000000..cac72503 --- /dev/null +++ b/firka/android/build.gradle.kts @@ -0,0 +1,49 @@ +import com.android.build.gradle.BaseExtension +import org.jetbrains.kotlin.gradle.plugin.extraProperties + +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + + // fix for verifyReleaseResources + + // note(4831c0): taken from https://github.com/isar/isar/issues/1662 + // note(4831c0): and adapted to kotlin + afterEvaluate { + 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" + } + } + if (hasProperty("android")) { + val androidExtension = extensions.getByName("android") as BaseExtension + androidExtension.apply { + // Set namespace if it's not already set + if (!extraProperties.has("namespace")) { + extraProperties["namespace"] = project.group.toString() + } + } + } + } + // =============================== + + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/firka/android/gradle.properties b/firka/android/gradle.properties new file mode 100644 index 00000000..f018a618 --- /dev/null +++ b/firka/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/firka/android/gradle/wrapper/gradle-wrapper.properties b/firka/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..afa1e8eb --- /dev/null +++ b/firka/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +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 diff --git a/firka/android/settings.gradle.kts b/firka/android/settings.gradle.kts new file mode 100644 index 00000000..a439442c --- /dev/null +++ b/firka/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +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 +} + +include(":app") diff --git a/firka/assets/fonts/Figtree-VariableFont_wght.ttf b/firka/assets/fonts/Figtree-VariableFont_wght.ttf new file mode 100644 index 00000000..06f9fe57 Binary files /dev/null and b/firka/assets/fonts/Figtree-VariableFont_wght.ttf differ diff --git a/firka/assets/fonts/Montserrat-VariableFont_wght.ttf b/firka/assets/fonts/Montserrat-VariableFont_wght.ttf new file mode 100644 index 00000000..df7379cd Binary files /dev/null and b/firka/assets/fonts/Montserrat-VariableFont_wght.ttf differ diff --git a/firka/assets/fonts/RobotoMono-VariableFont_wght.ttf b/firka/assets/fonts/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 00000000..3a2d704b Binary files /dev/null and b/firka/assets/fonts/RobotoMono-VariableFont_wght.ttf differ diff --git a/firka/assets/images/carousel/slide1.png b/firka/assets/images/carousel/slide1.png new file mode 100644 index 00000000..94a36b41 Binary files /dev/null and b/firka/assets/images/carousel/slide1.png differ diff --git a/firka/assets/images/carousel/slide2.png b/firka/assets/images/carousel/slide2.png new file mode 100644 index 00000000..968ac046 Binary files /dev/null and b/firka/assets/images/carousel/slide2.png differ diff --git a/firka/assets/images/carousel/slide3.png b/firka/assets/images/carousel/slide3.png new file mode 100644 index 00000000..53b6560e Binary files /dev/null and b/firka/assets/images/carousel/slide3.png differ diff --git a/firka/assets/images/carousel/slide4.png b/firka/assets/images/carousel/slide4.png new file mode 100644 index 00000000..979ebc4f Binary files /dev/null and b/firka/assets/images/carousel/slide4.png differ diff --git a/firka/assets/images/logos/colored_logo.png b/firka/assets/images/logos/colored_logo.png new file mode 100644 index 00000000..63456bc2 Binary files /dev/null and b/firka/assets/images/logos/colored_logo.png differ diff --git a/firka/assets/images/logos/colored_logo_only_mustache.png b/firka/assets/images/logos/colored_logo_only_mustache.png new file mode 100644 index 00000000..08f05518 Binary files /dev/null and b/firka/assets/images/logos/colored_logo_only_mustache.png differ diff --git a/firka/assets/images/logos/colored_logo_without_mustache.png b/firka/assets/images/logos/colored_logo_without_mustache.png new file mode 100644 index 00000000..8eb844e3 Binary files /dev/null and b/firka/assets/images/logos/colored_logo_without_mustache.png differ diff --git a/firka/assets/images/logos/dave.svg b/firka/assets/images/logos/dave.svg new file mode 100644 index 00000000..0eaf9eb1 --- /dev/null +++ b/firka/assets/images/logos/dave.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/firka/assets/images/logos/loading.gif b/firka/assets/images/logos/loading.gif new file mode 100644 index 00000000..8c4d0f69 Binary files /dev/null and b/firka/assets/images/logos/loading.gif differ diff --git a/firka/assets/images/logos/monochrome_logo.png b/firka/assets/images/logos/monochrome_logo.png new file mode 100644 index 00000000..26ed4833 Binary files /dev/null and b/firka/assets/images/logos/monochrome_logo.png differ diff --git a/firka/assets/images/logos/splash.png b/firka/assets/images/logos/splash.png new file mode 100644 index 00000000..daded963 Binary files /dev/null and b/firka/assets/images/logos/splash.png differ diff --git a/firka/assets/majesticons/cupFilled.svg b/firka/assets/majesticons/cupFilled.svg new file mode 100644 index 00000000..5f741a85 --- /dev/null +++ b/firka/assets/majesticons/cupFilled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/firka/assets/majesticons/parkSolidSchool.svg b/firka/assets/majesticons/parkSolidSchool.svg new file mode 100644 index 00000000..5caeb669 --- /dev/null +++ b/firka/assets/majesticons/parkSolidSchool.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/firka/assets/majesticons/sunSolid.svg b/firka/assets/majesticons/sunSolid.svg new file mode 100644 index 00000000..37f133d6 --- /dev/null +++ b/firka/assets/majesticons/sunSolid.svg @@ -0,0 +1,3 @@ + + + diff --git a/firka/devtools_options.yaml b/firka/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/firka/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/firka/flutter_launcher_icons.yaml b/firka/flutter_launcher_icons.yaml new file mode 100644 index 00000000..7342d0fc --- /dev/null +++ b/firka/flutter_launcher_icons.yaml @@ -0,0 +1,11 @@ +flutter_launcher_icons: + generate: true + android: "launcher_icon" + image_path: "assets/images/logos/colored_logo.png" + adaptive_icon_monochrome: "assets/images/logos/monochrome_logo.png" + adaptive_icon_background: "assets/images/logos/colored_logo_without_mustache.png" + adaptive_icon_foreground: "assets/images/logos/colored_logo_only_mustache.png" + adaptive_icon_foreground_inset: 0 + min_sdk_android: 21 + ios: true + remove_alpha_channel_ios: true diff --git a/firka/integration_test/phone_main_home_test.dart b/firka/integration_test/phone_main_home_test.dart new file mode 100644 index 00000000..cd2330ad --- /dev/null +++ b/firka/integration_test/phone_main_home_test.dart @@ -0,0 +1,42 @@ +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 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; + }); + }); + }); +} diff --git a/firka/integration_test/phone_main_login_test.dart b/firka/integration_test/phone_main_login_test.dart new file mode 100644 index 00000000..da0bdcde --- /dev/null +++ b/firka/integration_test/phone_main_login_test.dart @@ -0,0 +1,24 @@ +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 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; + }); + }); + }); +} diff --git a/firka/integration_test/test_helpers.dart b/firka/integration_test/test_helpers.dart new file mode 100644 index 00000000..bce49963 --- /dev/null +++ b/firka/integration_test/test_helpers.dart @@ -0,0 +1,42 @@ +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 isWear() async { + const platform = MethodChannel('firka.app/main'); + + return await platform.invokeMethod("isWear"); +} + +Future isPhone() async { + return !(await isWear()); +} + +Future 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 waitUntil(Duration timeout, WidgetTester tester, + Future 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"); +} diff --git a/firka/integration_test/wear_main_home_test.dart b/firka/integration_test/wear_main_home_test.dart new file mode 100644 index 00000000..f91ec096 --- /dev/null +++ b/firka/integration_test/wear_main_home_test.dart @@ -0,0 +1,43 @@ +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/wear_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 main() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + await resetAppData(); + setApiUrls(); + + group('main', () { + testWidgets('WearInitializationScreen -> WearHomeScreen', (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(WearInitializationScreen()); + + await waitUntil(Duration(minutes: 2), tester, () async { + var ele = find.byKey(const Key('wearHomeScreen')); + return ele.allCandidates.isNotEmpty; + }); + }); + }); +} diff --git a/firka/integration_test/wear_main_login_test.dart b/firka/integration_test/wear_main_login_test.dart new file mode 100644 index 00000000..5cdbe7ad --- /dev/null +++ b/firka/integration_test/wear_main_login_test.dart @@ -0,0 +1,24 @@ +import 'package:firka/wear_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 main() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + await resetAppData(); + setApiUrls(); + + group('main', () { + testWidgets('WearInitializationScreen -> LoginScreen', (tester) async { + await tester.pumpWidget(WearInitializationScreen()); + + await waitUntil(Duration(minutes: 2), tester, () async { + var ele = find.byKey(const Key('wearLoginScreen')); + return ele.allCandidates.isNotEmpty; + }); + }); + }); +} diff --git a/firka/ios/.gitignore b/firka/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/firka/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/firka/ios/Podfile b/firka/ios/Podfile new file mode 100644 index 00000000..e549ee22 --- /dev/null +++ b/firka/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/firka/ios/Runner.xcodeproj/project.pbxproj b/firka/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..dab4eaf8 --- /dev/null +++ b/firka/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,746 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + D7D2811832CF5E685A4F5A87 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8A91E5FB12F3A24DF62B6CFD /* Pods_RunnerTests.framework */; }; + E06BAF98A2AEC7BACF0F7CAC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 808A0A66595FECE399DC7B5A /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5A9D2BE8831E80F19E970980 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 808A0A66595FECE399DC7B5A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8A91E5FB12F3A24DF62B6CFD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8C55AAFC1041A8E33761B338 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 93978DA8474848CEFB44209E /* 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 = ""; }; + 9521A2D41979DA4A7C58CBCA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + E310C640BED39C8452C21A3D /* 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 = ""; }; + E86BEBB9C5FE3725DC350638 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E06BAF98A2AEC7BACF0F7CAC /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B42BD80F4ACF8486AC228179 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D7D2811832CF5E685A4F5A87 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 3D054DA13C4004147570171E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 808A0A66595FECE399DC7B5A /* Pods_Runner.framework */, + 8A91E5FB12F3A24DF62B6CFD /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 54BF1C4745BD3B98B608F75F /* Pods */ = { + isa = PBXGroup; + children = ( + E310C640BED39C8452C21A3D /* Pods-Runner.debug.xcconfig */, + E86BEBB9C5FE3725DC350638 /* Pods-Runner.release.xcconfig */, + 9521A2D41979DA4A7C58CBCA /* Pods-Runner.profile.xcconfig */, + 8C55AAFC1041A8E33761B338 /* Pods-RunnerTests.debug.xcconfig */, + 5A9D2BE8831E80F19E970980 /* Pods-RunnerTests.release.xcconfig */, + 93978DA8474848CEFB44209E /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 54BF1C4745BD3B98B608F75F /* Pods */, + 3D054DA13C4004147570171E /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 8F3C6118AFEBE9EDD3A076F9 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + B42BD80F4ACF8486AC228179 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 14A120CBD5B1DF3CD8A56326 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + FE40F8FDE1A80781DC9BF3B4 /* [CP] Embed Pods Frameworks */, + D7AC478660B5C97BE3D6A40B /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 14A120CBD5B1DF3CD8A56326 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 8F3C6118AFEBE9EDD3A076F9 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + D7AC478660B5C97BE3D6A40B /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + FE40F8FDE1A80781DC9BF3B4 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8C55AAFC1041A8E33761B338 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5A9D2BE8831E80F19E970980 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 93978DA8474848CEFB44209E /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = app.firka.firka; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/firka/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/firka/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/firka/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/firka/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/firka/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/firka/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/firka/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/firka/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/firka/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/firka/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firka/ios/Runner.xcworkspace/contents.xcworkspacedata b/firka/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/firka/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/firka/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/firka/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/firka/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/firka/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/firka/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/firka/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/firka/ios/Runner/AppDelegate.swift b/firka/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..62666446 --- /dev/null +++ b/firka/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d0d98aa1 --- /dev/null +++ b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..934cb4c8 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..b66a956b Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..5a474fb4 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..34ed1624 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..8c34c4ce Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..5a9e9a20 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..61fc4c00 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..5a474fb4 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..375d83ad Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..4f1bc27a Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..70dc3934 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..5508f134 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..2fcba8de Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..693ab066 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..4f1bc27a Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..27d4390f Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..630f7d94 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..a8161896 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..f0b37bd1 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..2364c7c8 Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..f0603a5c Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/firka/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/firka/ios/Runner/Base.lproj/LaunchScreen.storyboard b/firka/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/firka/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firka/ios/Runner/Base.lproj/Main.storyboard b/firka/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/firka/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/firka/ios/Runner/Info.plist b/firka/ios/Runner/Info.plist new file mode 100644 index 00000000..d9fb2ec4 --- /dev/null +++ b/firka/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Firka + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + firka + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/firka/ios/Runner/Runner-Bridging-Header.h b/firka/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/firka/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/firka/ios/RunnerTests/RunnerTests.swift b/firka/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/firka/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/firka/l10n.yml b/firka/l10n.yml new file mode 100644 index 00000000..fca49bc4 --- /dev/null +++ b/firka/l10n.yml @@ -0,0 +1,3 @@ +arb-dir: lib/l10n +template-arb-file: app_hu.arb +output-localization-file: app_localizations.dart \ No newline at end of file diff --git a/firka/lib/helpers/api/client/kreta_client.dart b/firka/lib/helpers/api/client/kreta_client.dart new file mode 100644 index 00000000..215aa77f --- /dev/null +++ b/firka/lib/helpers/api/client/kreta_client.dart @@ -0,0 +1,519 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:dio/dio.dart'; +import 'package:firka/helpers/api/model/homework.dart'; +import 'package:firka/helpers/api/model/timetable.dart'; +import 'package:firka/helpers/db/models/generic_cache_model.dart'; +import 'package:firka/helpers/db/models/homework_cache_model.dart'; +import 'package:firka/helpers/db/models/timetable_cache_model.dart'; +import 'package:intl/intl.dart'; +import 'package:isar/isar.dart'; + +import '../../../main.dart'; +import '../../db/models/token_model.dart'; +import '../../db/util.dart'; +import '../../debug_helper.dart'; +import '../consts.dart'; +import '../model/grade.dart'; +import '../model/notice_board.dart'; +import '../model/omission.dart'; +import '../model/student.dart'; +import '../model/test.dart'; +import '../token_grant.dart'; + +class ApiResponse { + T? response; + int statusCode; + String? err; + bool cached; + + ApiResponse( + this.response, + this.statusCode, + this.err, + this.cached, + ); + + @override + String toString() { + return "ApiResponse(" + "response: $response, " + "statusCode: $statusCode, " + "err: \"$err\", " + "cached: $cached" + ")"; + } +} + +class KretaClient { + bool _tokenMutex = false; + TokenModel model; + Isar isar; + + KretaClient(this.model, this.isar); + + Future _mutexCallback(Future Function() callback) async { + while (_tokenMutex) { + await Future.delayed(const Duration(milliseconds: 50)); + } + _tokenMutex = true; + try { + return callback(); + } finally { + _tokenMutex = false; + } + } + + Future _authReq(String method, String url, [Object? data]) async { + var localToken = await _mutexCallback(() async { + var now = timeNow(); + + if (now.millisecondsSinceEpoch >= + model.expiryDate!.millisecondsSinceEpoch) { + var extended = await extendToken(model); + var tokenModel = TokenModel.fromResp(extended); + + await isar.writeTxn(() async { + await isar.tokenModels.put(tokenModel); + }); + + model = tokenModel; + } + + return model.accessToken!; + }); + + final headers = { + // "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "accept": "*/*", + "user-agent": "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0", + "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 { + var resp = await _authReq(method, url, data); + + return (resp.data, resp.statusCode!); + } + + Future<(dynamic, int, Object?, bool)> _cachingGet( + CacheId id, String url, bool forceCache) async { + // 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??? + var cacheKey = model.studentId! + ((id.index + 1) * pow(10, 11)); + var cache = await isar.genericCacheModels.get(cacheKey as int); + + dynamic resp; + int statusCode; + try { + if (forceCache && cache != null) { + return (jsonDecode(cache.cacheData!), 200, null, true); + } + (resp, statusCode) = await _authJson("GET", url); + + if (statusCode >= 400) { + if (cache != null) { + return (jsonDecode(cache.cacheData!), statusCode, null, true); + } + } + } catch (ex) { + if (cache != null) { + return (jsonDecode(cache.cacheData!), 0, ex, true); + } else { + return (null, 0, ex, false); + } + } + + await isar.writeTxn(() async { + var cache = GenericCacheModel(); + cache.cacheKey = cacheKey; + cache.cacheData = jsonEncode(resp); + + isar.genericCacheModels.put(cache); + }); + + return (resp, statusCode, null, false); + } + + ApiResponse? studentCache; + + Future> getStudent({bool forceCache = true}) async { + if (forceCache && studentCache != null) return studentCache!; + var (resp, status, ex, cached) = await _cachingGet(CacheId.getStudent, + KretaEndpoints.getStudentUrl(model.iss!), forceCache); + + Student? student; + String? err; + try { + student = Student.fromJson(resp); + } catch (ex) { + err = ex.toString(); + } + + if (ex != null) { + err = ex.toString(); + } + + if (ex == null) studentCache = ApiResponse(student, 200, null, true); + + return ApiResponse(student, status, err, cached); + } + + ApiResponse>? noticeBoardCache; + + Future>> getNoticeBoard( + {bool forceCache = true}) async { + if (forceCache && noticeBoardCache != null) return noticeBoardCache!; + var (resp, status, ex, cached) = await _cachingGet(CacheId.getNoticeBoard, + KretaEndpoints.getNoticeBoard(model.iss!), forceCache); + + var items = List.empty(growable: true); + String? err; + try { + List rawItems = resp; + for (var item in rawItems) { + items.add(NoticeBoardItem.fromJson(item)); + } + } catch (ex) { + err = ex.toString(); + } + + if (ex != null) { + err = ex.toString(); + } + + if (err == null) noticeBoardCache = ApiResponse(items, 200, null, true); + + return ApiResponse(items, status, err, cached); + } + + ApiResponse>? gradeCache; + + Future>> getGrades({bool forceCache = true}) async { + if (forceCache && gradeCache != null) { + return gradeCache!; + } + var (resp, status, ex, cached) = await _cachingGet( + CacheId.getGrades, KretaEndpoints.getGrades(model.iss!), forceCache); + + var items = List.empty(growable: true); + String? err; + try { + List rawItems = resp; + for (var item in rawItems) { + items.add(Grade.fromJson(item)); + } + } catch (ex) { + err = ex.toString(); + } + + if (ex != null) { + err = ex.toString(); + } + + items.sort((a, b) => b.recordDate.compareTo(a.recordDate)); + + if (ex == null) gradeCache = ApiResponse(items, 200, null, true); + + return ApiResponse(items, status, err, cached); + } + + Future<(List, int, Object?, bool)> + _timedCachingGet( + IsarCollection cacheModel, + String endpoint, + DateTime from, + DateTime? to, + bool forceCache, + Future Function(dynamic, int) storeCache) async { + var cacheKey = genCacheKey(from, model.studentId!); + var cache = await cacheModel.get(cacheKey); + var formatter = DateFormat('yyyy-MM-dd'); + var fromStr = formatter.format(from); + var toStr = to != null ? formatter.format(to) : null; + var now = timeNow(); + + if (cache != null && (cache as dynamic).values == null) { + (cache as dynamic).values = List.empty(growable: true); + } + + List resp; + int statusCode; + try { + if (forceCache && cache != null) { + var items = List.empty(growable: true); + for (var item in (cache as dynamic).values) { + items.add(jsonDecode(item)); + } + + return (items, 200, null, true); + } + if (toStr == null) { + (resp, statusCode) = await _authJson( + "GET", + "$endpoint?" + "datumTol=$fromStr"); + } else { + (resp, statusCode) = await _authJson( + "GET", + "$endpoint?" + "datumTol=$fromStr&datumIg=$toStr"); + } + + if (statusCode >= 400) { + if (cache != null) { + var items = List.empty(growable: true); + for (var item in (cache as dynamic).values) { + items.add(jsonDecode(item)); + } + return (items, statusCode, null, true); + } + } + } catch (ex) { + if (cache != null) { + var items = List.empty(growable: true); + for (var item in (cache as dynamic).values) { + items.add(jsonDecode(item)); + } + return (items, 0, ex, true); + } else { + return (List.empty(growable: true), 0, ex, false); + } + } + + // only cache stuff in a 1 month frame + if (from.millisecondsSinceEpoch >= + now.subtract(Duration(days: 30)).millisecondsSinceEpoch) { + if (to == null || + to.millisecondsSinceEpoch <= + now.add(Duration(days: 30)).millisecondsSinceEpoch) { + await isar.writeTxn(() async { + await storeCache(resp, cacheKey); + }); + } + } + + return (resp, statusCode, null, false); + } + + /// Expects from and to to be 7 days apart + Future>> _getTimeTable( + DateTime from, DateTime to, bool forceCache) async { + var (resp, status, ex, cached) = + await _timedCachingGet( + isar.timetableCacheModels, + KretaEndpoints.getTimeTable(model.iss!), + from, + to, + forceCache, (dynamic resp, int cacheKey) async { + TimetableCacheModel cache = TimetableCacheModel(); + var rawClasses = List.empty(growable: true); + + for (var obj in resp) { + rawClasses.add(jsonEncode(obj)); + } + + cache.cacheKey = cacheKey; + cache.values = rawClasses; + + await isar.timetableCacheModels.put(cache as dynamic); + }); + + var items = List.empty(growable: true); + String? err; + try { + List rawItems = resp; + for (var item in rawItems) { + items.add(Lesson.fromJson(item)); + } + } catch (ex) { + err = ex.toString(); + } + + if (ex != null) { + err = ex.toString(); + } + + return ApiResponse(items, status, err, cached); + } + + /// Expects from and to to be 7 days apart + Future>> _getHomework( + DateTime from, DateTime to, bool forceCache) async { + var (resp, status, ex, cached) = await _timedCachingGet( + isar.homeworkCacheModels, + KretaEndpoints.getHomework(model.iss!), + from, + null, + forceCache, (dynamic resp, int cacheKey) async { + HomeworkCacheModel cache = HomeworkCacheModel(); + var rawClasses = List.empty(growable: true); + + for (var obj in resp) { + rawClasses.add(jsonEncode(obj)); + } + + cache.cacheKey = cacheKey; + cache.values = rawClasses; + + await isar.homeworkCacheModels.put(cache as dynamic); + }); + + var items = List.empty(growable: true); + String? err; + try { + List rawItems = resp; + for (var item in rawItems) { + items.add(Homework.fromJson(item)); + } + } catch (ex) { + err = ex.toString(); + } + + if (ex != null) { + err = ex.toString(); + } + + return ApiResponse(items, status, err, cached); + } + + /// Automatically aligns requests to start at Monday and end at Sunday + Future>> getHomework(DateTime from, DateTime to, + {bool forceCache = true}) async { + var homework = List.empty(growable: true); + String? err; + bool cached = true; + + for (var i = from.millisecondsSinceEpoch; + i < to.millisecondsSinceEpoch; + i += 604800000) { + var from = DateTime.fromMillisecondsSinceEpoch(i); + var start = from.subtract(Duration(days: from.weekday - 1)); + var end = start.add(Duration(days: 6)); + + var resp = await _getHomework(start, end, forceCache); + if (resp.err != null) { + err = resp.err; + if (!resp.cached) { + return resp; + } else { + homework.addAll(resp.response!); + } + } else { + homework.addAll(resp.response!); + } + if (!resp.cached) cached = false; + } + + homework.sort((a, b) => a.startDate.compareTo(b.startDate)); + homework = homework.where((h) => h.dueDate.isAfter(timeNow())).toList(); + + return ApiResponse(homework, 200, err, cached); + } + + /// Automatically aligns requests to start at Monday and end at Sunday + Future>> getTimeTable(DateTime from, DateTime to, + {bool forceCache = true}) async { + var lessons = List.empty(growable: true); + String? err; + bool cached = true; + + for (var i = from.millisecondsSinceEpoch; + i < to.millisecondsSinceEpoch; + i += 604800000) { + var from = DateTime.fromMillisecondsSinceEpoch(i); + var start = from.subtract(Duration(days: from.weekday - 1)); + var end = start.add(Duration(days: 6)); + + var resp = await _getTimeTable(start, end, forceCache); + if (resp.err != null) { + err = resp.err; + if (!resp.cached) { + return resp; + } else { + lessons.addAll(resp.response!); + } + } else { + lessons.addAll(resp.response!); + } + if (!resp.cached) cached = false; + } + + lessons.sort((a, b) => a.start.compareTo(b.start)); + lessons = lessons + .where( + (lesson) => lesson.start.isAfter(from) && lesson.end.isBefore(to)) + .toList(); + + return ApiResponse(lessons, 200, err, cached); + } + + Future>> getTests({bool forceCache = true}) async { + var (resp, status, ex, cached) = await _cachingGet( + CacheId.getTests, KretaEndpoints.getTests(model.iss!), forceCache); + + var items = List.empty(growable: true); + String? err; + try { + List rawItems = resp; + for (var item in rawItems) { + items.add(Test.fromJson(item)); + } + } catch (ex) { + err = ex.toString(); + } + + if (ex != null) { + err = ex.toString(); + } + + // items.sort((a, b) => a.date.compareTo(b.date)); + + return ApiResponse(items, status, err, cached); + } + + ApiResponse>? omissionsCache; + + Future>> getOmissions( + {bool forceCache = true}) async { + if (omissionsCache != null) return omissionsCache!; + var (resp, status, ex, cached) = await _cachingGet(CacheId.getOmissions, + KretaEndpoints.getOmissions(model.iss!), forceCache); + + var items = List.empty(growable: true); + String? err; + try { + List rawItems = resp; + for (var item in rawItems) { + items.add(Omission.fromJson(item)); + } + } catch (ex) { + err = ex.toString(); + } + + if (ex != null) { + err = ex.toString(); + } + + items.sort((a, b) => a.date.compareTo(b.date)); + + if (ex == null) omissionsCache = ApiResponse(items, 200, null, true); + + return ApiResponse(items, status, err, cached); + } + + void evictMemCache() { + studentCache = null; + noticeBoardCache = null; + gradeCache = null; + omissionsCache = null; + } +} diff --git a/firka/lib/helpers/api/consts.dart b/firka/lib/helpers/api/consts.dart new file mode 100644 index 00000000..2896f576 --- /dev/null +++ b/firka/lib/helpers/api/consts.dart @@ -0,0 +1,52 @@ +/* + Firka, alternative e-Kréta client. + Copyright (C) 2025 QwIT Development + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +class Constants { + static const clientId = "kreta-ellenorzo-student-mobile-ios"; +} + +class KretaEndpoints { + static String kretaBase = "e-kreta.hu"; + static String kreta(String iss) { + if (iss == "firka-test") { + return kretaBase; + } else { + return "https://$iss.$kretaBase"; + } + } + + static String kretaIdp = "https://idp.e-kreta.hu"; + static String kretaLoginUrl = + "$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"; + static String tokenGrantUrl = "$kretaIdp/connect/token"; + + static String getStudentUrl(String iss) => + "${kreta(iss)}/ellenorzo/v3/sajat/TanuloAdatlap"; + static String getNoticeBoard(String iss) => + "${kreta(iss)}/ellenorzo/v3/sajat/FaliujsagElemek"; + static String getGrades(String iss) => + "${kreta(iss)}/ellenorzo/v3/sajat/Ertekelesek"; + static String getTimeTable(String iss) => + "${kreta(iss)}/ellenorzo/v3/sajat/OrarendElemek"; + static String getOmissions(String iss) => + "${kreta(iss)}/ellenorzo/v3/sajat/Mulasztasok"; + static String getHomework(String iss) => + "${kreta(iss)}/ellenorzo/v3/sajat/HaziFeladatok"; + static String getTests(String iss) => + "${kreta(iss)}/ellenorzo/v3/sajat/BejelentettSzamonkeresek"; +} diff --git a/firka/lib/helpers/api/model/generic.dart b/firka/lib/helpers/api/model/generic.dart new file mode 100644 index 00000000..1e63b74b --- /dev/null +++ b/firka/lib/helpers/api/model/generic.dart @@ -0,0 +1,58 @@ +class NameUidDesc { + final String uid; + final String? name; + final String? description; + + NameUidDesc( + {required this.uid, required this.name, required this.description}); + + factory NameUidDesc.fromJson(Map json) { + return NameUidDesc( + uid: json['Uid'], name: json['Nev'], description: json['Leiras']); + } + + @override + String toString() { + return 'NameUidDesc(' + 'uid: "$uid", ' + 'name: "$name", ' + 'description: "$description"' + ')'; + } +} + +class NameUid { + final String uid; + final String name; + + NameUid({ + required this.uid, + required this.name, + }); + + factory NameUid.fromJson(Map json) { + return NameUid( + uid: json['Uid'], + name: json['Nev'], + ); + } +} + +class UidObj { + final String uid; + + UidObj({required this.uid}); + + factory UidObj.fromJson(Map json) { + return UidObj( + uid: json['Uid'], + ); + } + + @override + String toString() { + return 'UidObj(' + 'uid: "$uid"' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/grade.dart b/firka/lib/helpers/api/model/grade.dart new file mode 100644 index 00000000..ef21ab11 --- /dev/null +++ b/firka/lib/helpers/api/model/grade.dart @@ -0,0 +1,90 @@ +import 'package:firka/helpers/api/model/generic.dart'; +import 'package:firka/helpers/api/model/subject.dart'; + +class Grade { + final String uid; + final DateTime recordDate; + final DateTime creationDate; + final DateTime? ackDate; + final Subject subject; + final String? topic; + final NameUidDesc type; + final NameUidDesc? mode; + NameUidDesc valueType; + final String teacher; + final String? kind; + int? numericValue; + final String strValue; + final int? weightPercentage; + final String? shortStrValue; + final UidObj? classGroup; + final int sortIndex; + + Grade( + {required this.uid, + required this.recordDate, + required this.creationDate, + this.ackDate, + required this.subject, + this.topic, + required this.type, + this.mode, + required this.valueType, + required this.teacher, + this.kind, + this.numericValue, + required this.strValue, + this.weightPercentage, + this.shortStrValue, + this.classGroup, + required this.sortIndex}); + + factory Grade.fromJson(Map json) { + return Grade( + uid: json['Uid'], + recordDate: DateTime.parse(json['RogzitesDatuma']), + creationDate: DateTime.parse(json['KeszitesDatuma']), + ackDate: json['LattamozasDatuma'] != null + ? DateTime.parse(json['LattamozasDatuma']) + : null, + subject: Subject.fromJson(json['Tantargy']), + topic: json['Tema'], + type: NameUidDesc.fromJson(json['Tipus']), + mode: json['Mod'] != null ? NameUidDesc.fromJson(json['Mod']) : null, + valueType: NameUidDesc.fromJson(json['ErtekFajta']), + teacher: json['ErtekeloTanarNeve'], + kind: json['Kind'], + numericValue: json['SzamErtek'], + strValue: json['SzovegesErtek'], + weightPercentage: json['SulySzazalekErteke'], + shortStrValue: json['SzovegesErtekelesRovidNev'], + classGroup: json['OsztalyCsoport'] != null + ? UidObj.fromJson(json['OsztalyCsoport']) + : null, + sortIndex: json['SortIndex'], + ); + } + + @override + String toString() { + return 'Grade(' + 'uid: "$uid", ' + 'recordDate: "$recordDate", ' + 'creationDate: "$creationDate", ' + 'ackDate: "${ackDate ?? 'null'}", ' + 'subject: $subject, ' + 'topic: "${topic ?? 'null'}", ' + 'type: $type, ' + 'mode: ${mode ?? 'null'}, ' + 'valueType: $valueType, ' + 'teacher: "$teacher", ' + 'kind: "${kind ?? 'null'}", ' + 'numericValue: ${numericValue ?? 'null'}, ' + 'strValue: "$strValue", ' + 'weightPercentage: ${weightPercentage ?? 'null'}, ' + 'shortStrValue: "${shortStrValue ?? 'null'}", ' + 'classGroup: ${classGroup ?? 'null'}, ' + 'sortIndex: $sortIndex' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/guardian.dart b/firka/lib/helpers/api/model/guardian.dart new file mode 100644 index 00000000..a324abfb --- /dev/null +++ b/firka/lib/helpers/api/model/guardian.dart @@ -0,0 +1,52 @@ +/* + Firka, alternative e-Kréta client. + Copyright (C) 2025 QwIT Development + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +class Guardian { + final String? email; + final bool isLegalRepresentative; + final String? name; + final String? phoneNumber; + final String uid; + + Guardian( + {required this.email, + required this.isLegalRepresentative, + required this.name, + required this.phoneNumber, + required this.uid}); + + factory Guardian.fromJson(Map json) { + return Guardian( + email: json['EmailCim'], + isLegalRepresentative: json['IsTorvenyesKepviselo'], + name: json['Nev'], + phoneNumber: json['Telefonszam'], + uid: json['Uid']); + } + + @override + String toString() { + return 'Guardian(' + 'email: "$email", ' + 'isLegalRepresentative: $isLegalRepresentative, ' + 'name: "$name", ' + 'phoneNumber: "$phoneNumber", ' + 'uid: "$uid"' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/homework.dart b/firka/lib/helpers/api/model/homework.dart new file mode 100644 index 00000000..8618e26a --- /dev/null +++ b/firka/lib/helpers/api/model/homework.dart @@ -0,0 +1,70 @@ +import 'package:firka/helpers/api/model/subject.dart'; + +import 'generic.dart'; + +class Homework { + final String uid; + final Subject subject; + final String subjectName; + final String teacherName; + final String description; + final DateTime startDate; + final DateTime dueDate; + final DateTime creationDate; + final bool isCreatedByTeacher; + final bool isDone; + final bool canBeSubmitted; + final UidObj classGroup; + final bool canAttach; + + Homework( + {required this.uid, + required this.subject, + required this.subjectName, + required this.teacherName, + required this.description, + required this.startDate, + required this.dueDate, + required this.creationDate, + required this.isCreatedByTeacher, + required this.isDone, + required this.canBeSubmitted, + required this.classGroup, + required this.canAttach}); + + factory Homework.fromJson(Map json) { + return Homework( + uid: json["Uid"], + subject: Subject.fromJson(json["Tantargy"]), + subjectName: json["TantargyNeve"], + teacherName: json["RogzitoTanarNeve"], + description: json["Szoveg"], + startDate: DateTime.parse(json["FeladasDatuma"]).toLocal(), + dueDate: DateTime.parse(json["HataridoDatuma"]).toLocal(), + creationDate: DateTime.parse(json["RogzitesIdopontja"]).toLocal(), + isCreatedByTeacher: json["IsTanarRogzitette"], + isDone: json["IsMegoldva"], + canBeSubmitted: json["IsBeadhato"], + classGroup: UidObj.fromJson(json["OsztalyCsoport"]), + canAttach: json["IsCsatolasEngedelyezes"]); + } + + @override + String toString() { + return 'Homework(' + 'uid: "$uid", ' + 'subject: $subject, ' + 'subjectName: "$subjectName", ' + 'teacherName: "$teacherName", ' + 'description: "$description", ' + 'startDate: $startDate, ' + 'dueDate: $dueDate, ' + 'creationDate: $creationDate, ' + 'isCreatedByTeacher: $isCreatedByTeacher, ' + 'isDone: $isDone, ' + 'canBeSubmitted: $canBeSubmitted, ' + 'classGroup: $classGroup, ' + 'canAttach: $canAttach' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/institution.dart b/firka/lib/helpers/api/model/institution.dart new file mode 100644 index 00000000..d390ab7a --- /dev/null +++ b/firka/lib/helpers/api/model/institution.dart @@ -0,0 +1,100 @@ +/* + Firka, alternative e-Kréta client. + Copyright (C) 2025 QwIT Development + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +class Institution { + final CustomizationSettings customizationSettings; + final String shortName; + final List systemModuleList; + final String uid; + + Institution( + {required this.customizationSettings, + required this.shortName, + required this.systemModuleList, + required this.uid}); + + factory Institution.fromJson(Map json) { + var systemModuleList = List.empty(growable: true); + + for (var item in json['Rendszermodulok']) { + systemModuleList.add(SystemModule.fromJson(item)); + } + + return Institution( + customizationSettings: + CustomizationSettings.fromJson(json['TestreszabasBeallitasok']), + shortName: json['RovidNev'], + systemModuleList: systemModuleList, + uid: json['Uid'], + ); + } +} + +class CustomizationSettings { + final int delayForNotifications; + final bool isClassAverageVisible; + final bool isLessonsThemeVisible; + final String nextServerDeployAsString; + + CustomizationSettings( + {required this.delayForNotifications, + required this.isClassAverageVisible, + required this.isLessonsThemeVisible, + required this.nextServerDeployAsString}); + + factory CustomizationSettings.fromJson(Map json) { + return CustomizationSettings( + delayForNotifications: + json['ErtekelesekMegjelenitesenekKesleltetesenekMerteke'], + isClassAverageVisible: json['IsOsztalyAtlagMegjeleniteseEllenorzoben'], + isLessonsThemeVisible: json['IsTanorakTemajaMegtekinthetoEllenorzoben'], + nextServerDeployAsString: json['KovetkezoTelepitesDatuma']); + } + + @override + String toString() { + return 'CustomizationSettings(' + 'delayForNotifications: $delayForNotifications, ' + 'isClassAverageVisible: $isClassAverageVisible, ' + 'isLessonsThemeVisible: $isLessonsThemeVisible, ' + 'nextServerDeployAsString: "$nextServerDeployAsString"' + ')'; + } +} + +class SystemModule { + final bool isActive; + final String type; + final String? url; + + SystemModule({required this.isActive, required this.type, required this.url}); + + factory SystemModule.fromJson(Map json) { + return SystemModule( + isActive: json['IsAktiv'], type: json['Tipus'], url: json['Url']); + } + + @override + String toString() { + return 'SystemModule(' + 'isActive: $isActive, ' + 'type: "$type", ' + 'url: "$url"' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/notice_board.dart b/firka/lib/helpers/api/model/notice_board.dart new file mode 100644 index 00000000..ad709f5d --- /dev/null +++ b/firka/lib/helpers/api/model/notice_board.dart @@ -0,0 +1,42 @@ +class NoticeBoardItem { + final String uid; + final String author; + final DateTime validFrom; + final DateTime validTo; + final String title; + final String contentHTML; + final String contentText; + + NoticeBoardItem( + {required this.uid, + required this.author, + required this.validFrom, + required this.validTo, + required this.title, + required this.contentHTML, + required this.contentText}); + + factory NoticeBoardItem.fromJson(Map json) { + return NoticeBoardItem( + uid: json['Uid'], + author: json['RogzitoNeve'], + validFrom: DateTime.parse(json['ErvenyessegKezdete']), + validTo: DateTime.parse(json['ErvenyessegVege']), + title: json['Cim'], + contentHTML: json['Tartalom'], + contentText: json['TartalomText']); + } + + @override + String toString() { + return 'NoticeBoardItem(' + 'uid: "$uid", ' + 'author: "$author", ' + 'validFrom: "$validFrom", ' + 'validTo: "$validTo", ' + 'title: "$title", ' + 'contentHTML: "$contentHTML", ' + 'contentText: "$contentText"' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/omission.dart b/firka/lib/helpers/api/model/omission.dart new file mode 100644 index 00000000..88cf0b61 --- /dev/null +++ b/firka/lib/helpers/api/model/omission.dart @@ -0,0 +1,98 @@ +import 'package:firka/helpers/api/model/generic.dart'; +import 'package:firka/helpers/api/model/subject.dart'; + +class Omission { + final String uid; + final Subject subject; + final Class? c; + final DateTime date; + final String teacher; + final NameUidDesc? type; + final NameUidDesc? mode; + final int? lateForMin; + final DateTime createdAt; + final String state; + final NameUidDesc proofType; + final UidObj? classGroup; + + Omission({ + required this.uid, + required this.subject, + required this.c, + required this.date, + required this.teacher, + this.type, + this.mode, + this.lateForMin, + required this.createdAt, + required this.state, + required this.proofType, + this.classGroup, + }); + + factory Omission.fromJson(Map json) { + return Omission( + uid: json['Uid'], + subject: Subject.fromJson(json['Tantargy']), + c: json['Osztaly'] != null ? Class.fromJson(json['Osztaly']) : null, + date: DateTime.parse(json['Datum']), + teacher: json['RogzitoTanarNeve'], + type: json['Tipus'] != null ? NameUidDesc.fromJson(json['Tipus']) : null, + mode: json['Mod'] != null ? NameUidDesc.fromJson(json['Mod']) : null, + lateForMin: json['KesesPercben'], + createdAt: DateTime.parse(json['KeszitesDatuma']), + state: json['IgazolasAllapota'], + proofType: NameUidDesc.fromJson(json['IgazolasTipusa']), + classGroup: json['OsztalyCsoport'] != null + ? UidObj.fromJson(json['OsztalyCsoport']) + : null, + ); + } + + @override + String toString() { + return 'Omission(' + 'uid: "$uid", ' + 'subject: $subject, ' + 'c: $c, ' + 'date: $date, ' + 'teacher: "$teacher", ' + 'type: $type, ' + 'mode: $mode, ' + 'lateForMin: $lateForMin, ' + 'createdAt: $createdAt, ' + 'state: "$state", ' + 'proofType: $proofType, ' + 'classGroup: $classGroup' + ')'; + } +} + +class Class { + final DateTime start; + final DateTime end; + final int classNo; + + Class({ + required this.start, + required this.end, + required this.classNo, + }); + + factory Class.fromJson(Map json) { + return Class( + start: DateTime.parse(json['KezdoDatum']), + end: DateTime.parse(json['VegDatum']), + classNo: json['Oraszam'], + ); + } + + @override + String toString() { + return 'Class(' + 'start: "$start", ' + 'end: "$end", ' + 'classNo: $classNo' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/student.dart b/firka/lib/helpers/api/model/student.dart new file mode 100644 index 00000000..905d32c7 --- /dev/null +++ b/firka/lib/helpers/api/model/student.dart @@ -0,0 +1,133 @@ +/* + Firka, alternative e-Kréta client. + Copyright (C) 2025 QwIT Development + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import 'package:firka/helpers/api/model/guardian.dart'; +import 'package:firka/helpers/api/model/institution.dart'; +import 'package:firka/helpers/json_helper.dart'; +import 'package:intl/intl.dart'; + +class Student { + final List addressDataList; + final BankAccount bankAccount; + + // final int yearOfBirth; + // final int monthOfBirth; + // final int dayOfBirth; + final DateTime birthdate; + + final String? emailAddress; + final String name; + final String? phoneNumber; + + final String schoolYearUID; + final String uid; + + final List guardianList; + final String instituteCode; + final String instituteName; + + final Institution institution; + + Student( + {required this.addressDataList, + required this.bankAccount, + // required this.yearOfBirth, + // required this.monthOfBirth, + // required this.dayOfBirth, + required this.birthdate, + required this.emailAddress, + required this.name, + required this.phoneNumber, + required this.schoolYearUID, + required this.uid, + required this.guardianList, + required this.instituteCode, + required this.instituteName, + required this.institution}); + + factory Student.fromJson(Map json) { + var guardianList = List.empty(growable: true); + + for (var item in json['Gondviselok']) { + guardianList.add(Guardian.fromJson(item)); + } + + return Student( + addressDataList: listToTyped(json['Cimek']), + bankAccount: BankAccount.fromJson(json['Bankszamla']), + birthdate: DateFormat('yyyy-M-d').parse( + "${json['SzuletesiEv']}-${json['SzuletesiHonap']}-${json['SzuletesiNap']}"), + emailAddress: json['EmailCim'], + name: json['Nev'], + phoneNumber: json['Telefonszam'], + schoolYearUID: json['TanevUid'], + uid: json['Uid'], + guardianList: guardianList, + instituteCode: json['IntezmenyAzonosito'], + instituteName: json['IntezmenyNev'], + institution: Institution.fromJson(json['Intezmeny'])); + } + + @override + String toString() { + return 'Student(' + 'addressDataList: [$addressDataList], ' + 'bankAccount: $bankAccount, ' + 'birthDate: $birthdate, ' + 'emailAddress: "$emailAddress", ' + 'name: "$name", ' + 'phoneNumber: "$phoneNumber", ' + 'schoolYearUID: "$schoolYearUID", ' + 'uid: "$uid", ' + 'guardianList: [$guardianList], ' + 'instituteCode: "$instituteCode", ' + 'instituteName: "$instituteName", ' + ')'; + } +} + +class BankAccount { + final String? accountNumber; + final bool? isReadOnly; + final String? ownerName; + final int? ownerType; + + BankAccount( + {required this.accountNumber, + required this.isReadOnly, + required this.ownerName, + required this.ownerType}); + + factory BankAccount.fromJson(Map json) { + return BankAccount( + accountNumber: json['BankszamlaSzam'], + isReadOnly: json['IsReadOnly'], + ownerName: json['BankszamlaTulajdonosNeve'], + ownerType: json['BankszamlaTulajdonosTipusId']); + } + + @override + String toString() { + return 'BankAccount(' + 'accountNumber: "$accountNumber", ' + 'isReadOnly: "$isReadOnly", ' + 'ownerName: "$ownerName", ' + 'ownerType: "$ownerType"' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/subject.dart b/firka/lib/helpers/api/model/subject.dart new file mode 100644 index 00000000..6204fa06 --- /dev/null +++ b/firka/lib/helpers/api/model/subject.dart @@ -0,0 +1,32 @@ +import 'generic.dart'; + +class Subject { + final String uid; + final String name; + final NameUidDesc category; + final int sortIndex; + + Subject( + {required this.uid, + required this.name, + required this.category, + required this.sortIndex}); + + factory Subject.fromJson(Map json) { + return Subject( + uid: json['Uid'], + name: json['Nev'], + category: NameUidDesc.fromJson(json['Kategoria']), + sortIndex: json['SortIndex']); + } + + @override + String toString() { + return 'Subject(' + 'uid: "$uid", ' + 'name: "$name", ' + 'category: $category, ' + 'sortIndex: $sortIndex' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/test.dart b/firka/lib/helpers/api/model/test.dart new file mode 100644 index 00000000..199a2e45 --- /dev/null +++ b/firka/lib/helpers/api/model/test.dart @@ -0,0 +1,60 @@ +import 'package:firka/helpers/api/model/subject.dart'; + +import 'generic.dart'; + +class Test { + final String uid; + final DateTime date; + final DateTime reportDate; + final String teacherName; + final int lessonNumber; + final Subject subject; + final String subjectName; + final String theme; + final NameUidDesc method; + final UidObj classGroup; + + Test({ + required this.uid, + required this.date, + required this.reportDate, + required this.teacherName, + required this.lessonNumber, + required this.subject, + required this.subjectName, + required this.theme, + required this.method, + required this.classGroup, + }); + + factory Test.fromJson(Map json) { + return Test( + uid: json['Uid'], + date: DateTime.parse(json['Datum']), + reportDate: DateTime.parse(json['BejelentesDatuma']), + teacherName: json['RogzitoTanarNeve'], + lessonNumber: json['OrarendiOraOraszama'], + subject: Subject.fromJson(json['Tantargy']), + subjectName: json['TantargyNeve'], + theme: json['Temaja'], + method: NameUidDesc.fromJson(json['Modja']), + classGroup: UidObj.fromJson(json['OsztalyCsoport']), + ); + } + + @override + String toString() { + return 'Test(' + 'uid: "$uid", ' + 'date: $date, ' + 'reportDate: $reportDate, ' + 'teacherName: "$teacherName", ' + 'lessonNumber: $lessonNumber, ' + 'subject: $subject, ' + 'subjectName: "$subjectName", ' + 'theme: "$theme", ' + 'method: $method, ' + 'classGroup: $classGroup' + ')'; + } +} diff --git a/firka/lib/helpers/api/model/timetable.dart b/firka/lib/helpers/api/model/timetable.dart new file mode 100644 index 00000000..0ebba3a5 --- /dev/null +++ b/firka/lib/helpers/api/model/timetable.dart @@ -0,0 +1,149 @@ +import 'package:firka/helpers/api/model/generic.dart'; +import 'package:firka/helpers/api/model/subject.dart'; + +class Lesson { + final String uid; + final String date; + final DateTime start; + final DateTime end; + final String name; + final int? lessonNumber; + final int? lessonSeqNumber; + final NameUid? classGroup; + final String? teacher; + final Subject? subject; + final String? theme; + final String? roomName; + final NameUidDesc type; + final NameUidDesc? studentPresence; + final NameUidDesc state; + final String? substituteTeacher; + final String? homeworkUid; + final String? taskGroupUid; + final String? languageTaskGroupUid; + final String? assessmentUid; + final bool canStudentEditHomework; + final bool isHomeworkComplete; + final List attachments; + final bool isDigitalLesson; + final String? digitalDeviceList; + final String? digitalPlatformType; + final List digitalSupportDeviceTypeList; + final DateTime createdAt; + final DateTime lastModifiedAt; + + Lesson({ + required this.uid, + required this.date, + required this.start, + required this.end, + required this.name, + this.lessonNumber, + this.lessonSeqNumber, + this.classGroup, + this.teacher, + this.subject, + this.theme, + this.roomName, + required this.type, + this.studentPresence, + required this.state, + this.substituteTeacher, + this.homeworkUid, + this.taskGroupUid, + this.languageTaskGroupUid, + this.assessmentUid, + required this.canStudentEditHomework, + required this.isHomeworkComplete, + required this.attachments, + required this.isDigitalLesson, + this.digitalDeviceList, + this.digitalPlatformType, + required this.digitalSupportDeviceTypeList, + required this.createdAt, + required this.lastModifiedAt, + }); + + factory Lesson.fromJson(Map json) { + var attachments = List.empty(growable: true); + var rawAttachments = json['Csatolmanyok']; + + for (var attachment in rawAttachments) { + attachments.add(NameUid.fromJson(attachment)); + } + return Lesson( + uid: json['Uid'], + date: json['Datum'], + start: DateTime.parse(json['KezdetIdopont']), + end: DateTime.parse(json['VegIdopont']), + name: json['Nev'], + lessonNumber: json['Oraszam'], + lessonSeqNumber: json['OraEvesSorszama'], + classGroup: json['OsztalyCsoport'] != null + ? NameUid.fromJson(json['OsztalyCsoport']) + : null, + teacher: json['TanarNeve'], + subject: + json['Tantargy'] != null ? Subject.fromJson(json['Tantargy']) : null, + theme: json['Tema'], + roomName: json['TeremNeve'], + type: NameUidDesc.fromJson(json['Tipus']), + studentPresence: json['TanuloJelenlet'] != null + ? NameUidDesc.fromJson(json['TanuloJelenlet']) + : null, + state: NameUidDesc.fromJson(json['Allapot']), + substituteTeacher: json['HelyettesTanarNeve'], + homeworkUid: json['HaziFeladatUid'], + taskGroupUid: json['FeladatGroupUid'], + languageTaskGroupUid: json['NyelviFeladatGroupUid'], + assessmentUid: json['BejelentettSzamonkeresUid'], + canStudentEditHomework: json['IsTanuloHaziFeladatEnabled'], + isHomeworkComplete: json['IsHaziFeladatMegoldva'], + attachments: attachments, + isDigitalLesson: json['IsDigitalisOra'], + digitalDeviceList: json['DigitalisEszkozTipus'], + digitalPlatformType: json['DigitalisPlatformTipus'], + digitalSupportDeviceTypeList: + json['DigitalisTamogatoEszkozTipusList'] != null + ? List.from(json['DigitalisTamogatoEszkozTipusList']) + : List.empty(), + createdAt: DateTime.parse(json['Letrehozas']), + lastModifiedAt: DateTime.parse(json['UtolsoModositas']), + ); + } + + @override + String toString() { + return 'Lesson(' + 'uid: "$uid", ' + 'date: "$date", ' + 'start: $start, ' + 'end: $end, ' + 'name: "$name", ' + 'lessonNumber: $lessonNumber, ' + 'lessonSeqNumber: $lessonSeqNumber, ' + 'classGroup: $classGroup, ' + 'teacher: "$teacher", ' + 'subject: $subject, ' + 'theme: "$theme", ' + 'roomName: "$roomName", ' + 'type: $type, ' + 'studentPresence: $studentPresence, ' + 'state: $state, ' + 'substituteTeacher: "$substituteTeacher", ' + 'homeworkUid: "$homeworkUid", ' + 'taskGroupUid: "$taskGroupUid", ' + 'languageTaskGroupUid: "$languageTaskGroupUid", ' + 'assessmentUid: "$assessmentUid", ' + 'canStudentEditHomework: $canStudentEditHomework, ' + 'isHomeworkComplete: $isHomeworkComplete, ' + 'attachments: $attachments, ' + 'isDigitalLesson: $isDigitalLesson, ' + 'digitalDeviceList: "$digitalDeviceList", ' + 'digitalPlatformType: "$digitalPlatformType", ' + 'digitalSupportDeviceTypeList: $digitalSupportDeviceTypeList, ' + 'create: $createdAt, ' + 'lastModified: $lastModifiedAt' + ')'; + } +} diff --git a/firka/lib/helpers/api/resp/token_grant.dart b/firka/lib/helpers/api/resp/token_grant.dart new file mode 100644 index 00000000..6c05232d --- /dev/null +++ b/firka/lib/helpers/api/resp/token_grant.dart @@ -0,0 +1,54 @@ +/* + Firka, alternative e-Kréta client. + Copyright (C) 2025 QwIT Development + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +class TokenGrantResponse { + final String idToken; + final String accessToken; + final int expiresIn; + final String tokenType; + final String refreshToken; + final String scope; + + TokenGrantResponse( + {required this.idToken, + required this.accessToken, + required this.expiresIn, + required this.tokenType, + required this.refreshToken, + required this.scope}); + + factory TokenGrantResponse.fromJson(Map json) { + return TokenGrantResponse( + idToken: json['id_token'], + accessToken: json['access_token'], + expiresIn: json['expires_in'], + tokenType: json['token_type'], + refreshToken: json['refresh_token'], + scope: json['scope']); + } + + @override + String toString() { + return 'TokenGrant(idToken: "$idToken", accessToken: "$accessToken", ' + 'expiresIn: $expiresIn, ' + 'tokenType: "$tokenType", ' + 'refreshToken: "$refreshToken", ' + 'scope: "$scope"' + ')'; + } +} diff --git a/firka/lib/helpers/api/token_grant.dart b/firka/lib/helpers/api/token_grant.dart new file mode 100644 index 00000000..036d3d09 --- /dev/null +++ b/firka/lib/helpers/api/token_grant.dart @@ -0,0 +1,90 @@ +/* + Firka, alternative e-Kréta client. + Copyright (C) 2025 QwIT Development + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import 'package:dio/dio.dart'; +import 'package:firka/helpers/api/resp/token_grant.dart'; +import 'package:firka/helpers/db/models/token_model.dart'; + +import '../../main.dart'; +import 'consts.dart'; + +Future getAccessToken(String code) async { + final headers = const { + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "accept": "*/*", + "user-agent": "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0", + }; + + final formData = { + "code": code, + "code_verifier": "DSpuqj_HhDX4wzQIbtn8lr8NLE5wEi1iVLMtMK0jY6c", + "redirect_uri": + "https://mobil.e-kreta.hu/ellenorzo-student/prod/oauthredirect", + "client_id": Constants.clientId, + "grant_type": "authorization_code", + }; + + try { + final response = await dio.post(KretaEndpoints.tokenGrantUrl, + options: Options(headers: headers), data: formData); + + switch (response.statusCode) { + case 200: + return TokenGrantResponse.fromJson(response.data); + case 401: + throw Exception("Invalid grant"); + default: + throw Exception( + "Failed to get access token, response code: ${response.statusCode}"); + } + } catch (e) { + rethrow; + } +} + +Future extendToken(TokenModel model) async { + final headers = const { + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + "accept": "*/*", + "user-agent": "eKretaStudent/264745 CFNetwork/1494.0.7 Darwin/23.4.0", + }; + + final formData = { + "institute_code": model.iss!, + "refresh_token": model.refreshToken!, + "grant_type": "refresh_token", + "client_id": Constants.clientId, + }; + + try { + final response = await dio.post(KretaEndpoints.tokenGrantUrl, + options: Options(headers: headers), data: formData); + + switch (response.statusCode) { + case 200: + return TokenGrantResponse.fromJson(response.data); + case 401: + throw Exception("Invalid grant"); + default: + throw Exception( + "Failed to get access token, response code: ${response.statusCode}"); + } + } catch (e) { + rethrow; + } +} diff --git a/firka/lib/helpers/db/models/app_settings_model.dart b/firka/lib/helpers/db/models/app_settings_model.dart new file mode 100644 index 00000000..de9a71a0 --- /dev/null +++ b/firka/lib/helpers/db/models/app_settings_model.dart @@ -0,0 +1,12 @@ +import 'package:isar/isar.dart'; + +part 'app_settings_model.g.dart'; + +@collection +class AppSettingsModel { + Id? id; + bool? useCustomHost; + String? customHost; + + AppSettingsModel(); +} diff --git a/firka/lib/helpers/db/models/app_settings_model.g.dart b/firka/lib/helpers/db/models/app_settings_model.g.dart new file mode 100644 index 00000000..37b7cc38 --- /dev/null +++ b/firka/lib/helpers/db/models/app_settings_model.g.dart @@ -0,0 +1,571 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'app_settings_model.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetAppSettingsModelCollection on Isar { + IsarCollection get appSettingsModels => this.collection(); +} + +const AppSettingsModelSchema = CollectionSchema( + name: r'AppSettingsModel', + id: -638838212012723081, + properties: { + r'customHost': PropertySchema( + id: 0, + name: r'customHost', + type: IsarType.string, + ), + r'useCustomHost': PropertySchema( + id: 1, + name: r'useCustomHost', + type: IsarType.bool, + ) + }, + estimateSize: _appSettingsModelEstimateSize, + serialize: _appSettingsModelSerialize, + deserialize: _appSettingsModelDeserialize, + deserializeProp: _appSettingsModelDeserializeProp, + idName: r'ignored', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _appSettingsModelGetId, + getLinks: _appSettingsModelGetLinks, + attach: _appSettingsModelAttach, + version: '3.1.0+1', +); + +int _appSettingsModelEstimateSize( + AppSettingsModel object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.customHost; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + return bytesCount; +} + +void _appSettingsModelSerialize( + AppSettingsModel object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.customHost); + writer.writeBool(offsets[1], object.useCustomHost); +} + +AppSettingsModel _appSettingsModelDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = AppSettingsModel(); + object.customHost = reader.readStringOrNull(offsets[0]); + object.id = id; + object.useCustomHost = reader.readBoolOrNull(offsets[1]); + return object; +} + +P _appSettingsModelDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + case 1: + return (reader.readBoolOrNull(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _appSettingsModelGetId(AppSettingsModel object) { + return object.id ?? Isar.autoIncrement; +} + +List> _appSettingsModelGetLinks(AppSettingsModel object) { + return []; +} + +void _appSettingsModelAttach( + IsarCollection col, Id id, AppSettingsModel object) { + object.id = id; +} + +extension AppSettingsModelQueryWhereSort + on QueryBuilder { + QueryBuilder anyIgnored() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension AppSettingsModelQueryWhere + on QueryBuilder { + QueryBuilder + ignoredEqualTo(Id ignored) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: ignored, + upper: ignored, + )); + }); + } + + QueryBuilder + ignoredNotEqualTo(Id ignored) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: ignored, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: ignored, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: ignored, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: ignored, includeUpper: false), + ); + } + }); + } + + QueryBuilder + ignoredGreaterThan(Id ignored, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: ignored, includeLower: include), + ); + }); + } + + QueryBuilder + ignoredLessThan(Id ignored, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: ignored, includeUpper: include), + ); + }); + } + + QueryBuilder + ignoredBetween( + Id lowerIgnored, + Id upperIgnored, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerIgnored, + includeLower: includeLower, + upper: upperIgnored, + includeUpper: includeUpper, + )); + }); + } +} + +extension AppSettingsModelQueryFilter + on QueryBuilder { + QueryBuilder + customHostIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'customHost', + )); + }); + } + + QueryBuilder + customHostIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'customHost', + )); + }); + } + + QueryBuilder + customHostEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'customHost', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + customHostGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'customHost', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + customHostLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'customHost', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + customHostBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'customHost', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + customHostStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'customHost', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + customHostEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'customHost', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + customHostContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'customHost', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + customHostMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'customHost', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + customHostIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'customHost', + value: '', + )); + }); + } + + QueryBuilder + customHostIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'customHost', + value: '', + )); + }); + } + + QueryBuilder + ignoredIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'ignored', + )); + }); + } + + QueryBuilder + ignoredIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'ignored', + )); + }); + } + + QueryBuilder + ignoredEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'ignored', + value: value, + )); + }); + } + + QueryBuilder + ignoredGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'ignored', + value: value, + )); + }); + } + + QueryBuilder + ignoredLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'ignored', + value: value, + )); + }); + } + + QueryBuilder + ignoredBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'ignored', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + useCustomHostIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'useCustomHost', + )); + }); + } + + QueryBuilder + useCustomHostIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'useCustomHost', + )); + }); + } + + QueryBuilder + useCustomHostEqualTo(bool? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'useCustomHost', + value: value, + )); + }); + } +} + +extension AppSettingsModelQueryObject + on QueryBuilder {} + +extension AppSettingsModelQueryLinks + on QueryBuilder {} + +extension AppSettingsModelQuerySortBy + on QueryBuilder { + QueryBuilder + sortByCustomHost() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'customHost', Sort.asc); + }); + } + + QueryBuilder + sortByCustomHostDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'customHost', Sort.desc); + }); + } + + QueryBuilder + sortByUseCustomHost() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'useCustomHost', Sort.asc); + }); + } + + QueryBuilder + sortByUseCustomHostDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'useCustomHost', Sort.desc); + }); + } +} + +extension AppSettingsModelQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByCustomHost() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'customHost', Sort.asc); + }); + } + + QueryBuilder + thenByCustomHostDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'customHost', Sort.desc); + }); + } + + QueryBuilder + thenByIgnored() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'ignored', Sort.asc); + }); + } + + QueryBuilder + thenByIgnoredDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'ignored', Sort.desc); + }); + } + + QueryBuilder + thenByUseCustomHost() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'useCustomHost', Sort.asc); + }); + } + + QueryBuilder + thenByUseCustomHostDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'useCustomHost', Sort.desc); + }); + } +} + +extension AppSettingsModelQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByCustomHost({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'customHost', caseSensitive: caseSensitive); + }); + } + + QueryBuilder + distinctByUseCustomHost() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'useCustomHost'); + }); + } +} + +extension AppSettingsModelQueryProperty + on QueryBuilder { + QueryBuilder ignoredProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'ignored'); + }); + } + + QueryBuilder + customHostProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'customHost'); + }); + } + + QueryBuilder + useCustomHostProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'useCustomHost'); + }); + } +} diff --git a/firka/lib/helpers/db/models/generic_cache_model.dart b/firka/lib/helpers/db/models/generic_cache_model.dart new file mode 100644 index 00000000..026695dd --- /dev/null +++ b/firka/lib/helpers/db/models/generic_cache_model.dart @@ -0,0 +1,13 @@ +import 'package:isar/isar.dart'; + +part 'generic_cache_model.g.dart'; + +enum CacheId { getStudent, getNoticeBoard, getGrades, getOmissions, getTests } + +@collection +class GenericCacheModel { + Id? cacheKey; + String? cacheData; + + GenericCacheModel(); +} diff --git a/firka/lib/helpers/db/models/generic_cache_model.g.dart b/firka/lib/helpers/db/models/generic_cache_model.g.dart new file mode 100644 index 00000000..587d5d86 --- /dev/null +++ b/firka/lib/helpers/db/models/generic_cache_model.g.dart @@ -0,0 +1,494 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'generic_cache_model.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetGenericCacheModelCollection on Isar { + IsarCollection get genericCacheModels => this.collection(); +} + +const GenericCacheModelSchema = CollectionSchema( + name: r'GenericCacheModel', + id: 3174486726793780620, + properties: { + r'cacheData': PropertySchema( + id: 0, + name: r'cacheData', + type: IsarType.string, + ) + }, + estimateSize: _genericCacheModelEstimateSize, + serialize: _genericCacheModelSerialize, + deserialize: _genericCacheModelDeserialize, + deserializeProp: _genericCacheModelDeserializeProp, + idName: r'cacheKey', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _genericCacheModelGetId, + getLinks: _genericCacheModelGetLinks, + attach: _genericCacheModelAttach, + version: '3.1.0+1', +); + +int _genericCacheModelEstimateSize( + GenericCacheModel object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.cacheData; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + return bytesCount; +} + +void _genericCacheModelSerialize( + GenericCacheModel object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.cacheData); +} + +GenericCacheModel _genericCacheModelDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = GenericCacheModel(); + object.cacheData = reader.readStringOrNull(offsets[0]); + object.cacheKey = id; + return object; +} + +P _genericCacheModelDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _genericCacheModelGetId(GenericCacheModel object) { + return object.cacheKey ?? Isar.autoIncrement; +} + +List> _genericCacheModelGetLinks( + GenericCacheModel object) { + return []; +} + +void _genericCacheModelAttach( + IsarCollection col, Id id, GenericCacheModel object) { + object.cacheKey = id; +} + +extension GenericCacheModelQueryWhereSort + on QueryBuilder { + QueryBuilder + anyCacheKey() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension GenericCacheModelQueryWhere + on QueryBuilder { + QueryBuilder + cacheKeyEqualTo(Id cacheKey) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: cacheKey, + upper: cacheKey, + )); + }); + } + + QueryBuilder + cacheKeyNotEqualTo(Id cacheKey) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: false), + ); + } + }); + } + + QueryBuilder + cacheKeyGreaterThan(Id cacheKey, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: include), + ); + }); + } + + QueryBuilder + cacheKeyLessThan(Id cacheKey, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: include), + ); + }); + } + + QueryBuilder + cacheKeyBetween( + Id lowerCacheKey, + Id upperCacheKey, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerCacheKey, + includeLower: includeLower, + upper: upperCacheKey, + includeUpper: includeUpper, + )); + }); + } +} + +extension GenericCacheModelQueryFilter + on QueryBuilder { + QueryBuilder + cacheDataIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'cacheData', + )); + }); + } + + QueryBuilder + cacheDataIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'cacheData', + )); + }); + } + + QueryBuilder + cacheDataEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'cacheData', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + cacheDataGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'cacheData', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + cacheDataLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'cacheData', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + cacheDataBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'cacheData', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + cacheDataStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'cacheData', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + cacheDataEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'cacheData', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + cacheDataContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'cacheData', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + cacheDataMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'cacheData', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + cacheDataIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'cacheData', + value: '', + )); + }); + } + + QueryBuilder + cacheDataIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'cacheData', + value: '', + )); + }); + } + + QueryBuilder + cacheKeyIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'cacheKey', + )); + }); + } + + QueryBuilder + cacheKeyIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'cacheKey', + )); + }); + } + + QueryBuilder + cacheKeyEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'cacheKey', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } +} + +extension GenericCacheModelQueryObject + on QueryBuilder {} + +extension GenericCacheModelQueryLinks + on QueryBuilder {} + +extension GenericCacheModelQuerySortBy + on QueryBuilder { + QueryBuilder + sortByCacheData() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheData', Sort.asc); + }); + } + + QueryBuilder + sortByCacheDataDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheData', Sort.desc); + }); + } +} + +extension GenericCacheModelQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByCacheData() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheData', Sort.asc); + }); + } + + QueryBuilder + thenByCacheDataDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheData', Sort.desc); + }); + } + + QueryBuilder + thenByCacheKey() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheKey', Sort.asc); + }); + } + + QueryBuilder + thenByCacheKeyDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheKey', Sort.desc); + }); + } +} + +extension GenericCacheModelQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByCacheData({bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'cacheData', caseSensitive: caseSensitive); + }); + } +} + +extension GenericCacheModelQueryProperty + on QueryBuilder { + QueryBuilder cacheKeyProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'cacheKey'); + }); + } + + QueryBuilder + cacheDataProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'cacheData'); + }); + } +} diff --git a/firka/lib/helpers/db/models/homework_cache_model.dart b/firka/lib/helpers/db/models/homework_cache_model.dart new file mode 100644 index 00000000..ca92f083 --- /dev/null +++ b/firka/lib/helpers/db/models/homework_cache_model.dart @@ -0,0 +1,29 @@ +import 'package:isar/isar.dart'; + +import '../../debug_helper.dart'; +import '../util.dart'; + +part 'homework_cache_model.g.dart'; + +@collection +class HomeworkCacheModel extends DatedCacheEntry { + HomeworkCacheModel(); +} + +Future resetOldHomeworkCache(Isar isar) async { + var now = timeNow(); + var weeks = await isar.homeworkCacheModels.where().findAll(); + var weeksToRemove = List.empty(growable: true); + + for (var week in weeks) { + var date = getDate(week.cacheKey!); + + if (date.millisecondsSinceEpoch < + now.subtract(Duration(days: 30)).millisecondsSinceEpoch) { + weeksToRemove.add(week.cacheKey!); + } + } + await isar.writeTxn(() async { + await isar.homeworkCacheModels.deleteAll(weeksToRemove); + }); +} diff --git a/firka/lib/helpers/db/models/homework_cache_model.g.dart b/firka/lib/helpers/db/models/homework_cache_model.g.dart new file mode 100644 index 00000000..fa95e9b9 --- /dev/null +++ b/firka/lib/helpers/db/models/homework_cache_model.g.dart @@ -0,0 +1,562 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'homework_cache_model.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetHomeworkCacheModelCollection on Isar { + IsarCollection get homeworkCacheModels => + this.collection(); +} + +const HomeworkCacheModelSchema = CollectionSchema( + name: r'HomeworkCacheModel', + id: -356692531669197690, + properties: { + r'values': PropertySchema( + id: 0, + name: r'values', + type: IsarType.stringList, + ) + }, + estimateSize: _homeworkCacheModelEstimateSize, + serialize: _homeworkCacheModelSerialize, + deserialize: _homeworkCacheModelDeserialize, + deserializeProp: _homeworkCacheModelDeserializeProp, + idName: r'cacheKey', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _homeworkCacheModelGetId, + getLinks: _homeworkCacheModelGetLinks, + attach: _homeworkCacheModelAttach, + version: '3.1.0+1', +); + +int _homeworkCacheModelEstimateSize( + HomeworkCacheModel object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final list = object.values; + if (list != null) { + bytesCount += 3 + list.length * 3; + { + for (var i = 0; i < list.length; i++) { + final value = list[i]; + bytesCount += value.length * 3; + } + } + } + } + return bytesCount; +} + +void _homeworkCacheModelSerialize( + HomeworkCacheModel object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeStringList(offsets[0], object.values); +} + +HomeworkCacheModel _homeworkCacheModelDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = HomeworkCacheModel(); + object.cacheKey = id; + object.values = reader.readStringList(offsets[0]); + return object; +} + +P _homeworkCacheModelDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringList(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _homeworkCacheModelGetId(HomeworkCacheModel object) { + return object.cacheKey ?? Isar.autoIncrement; +} + +List> _homeworkCacheModelGetLinks( + HomeworkCacheModel object) { + return []; +} + +void _homeworkCacheModelAttach( + IsarCollection col, Id id, HomeworkCacheModel object) { + object.cacheKey = id; +} + +extension HomeworkCacheModelQueryWhereSort + on QueryBuilder { + QueryBuilder + anyCacheKey() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension HomeworkCacheModelQueryWhere + on QueryBuilder { + QueryBuilder + cacheKeyEqualTo(Id cacheKey) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: cacheKey, + upper: cacheKey, + )); + }); + } + + QueryBuilder + cacheKeyNotEqualTo(Id cacheKey) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: false), + ); + } + }); + } + + QueryBuilder + cacheKeyGreaterThan(Id cacheKey, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: include), + ); + }); + } + + QueryBuilder + cacheKeyLessThan(Id cacheKey, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: include), + ); + }); + } + + QueryBuilder + cacheKeyBetween( + Id lowerCacheKey, + Id upperCacheKey, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerCacheKey, + includeLower: includeLower, + upper: upperCacheKey, + includeUpper: includeUpper, + )); + }); + } +} + +extension HomeworkCacheModelQueryFilter + on QueryBuilder { + QueryBuilder + cacheKeyIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'cacheKey', + )); + }); + } + + QueryBuilder + cacheKeyIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'cacheKey', + )); + }); + } + + QueryBuilder + cacheKeyEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'cacheKey', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + valuesIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'values', + )); + }); + } + + QueryBuilder + valuesIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'values', + )); + }); + } + + QueryBuilder + valuesElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'values', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'values', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'values', + value: '', + )); + }); + } + + QueryBuilder + valuesElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'values', + value: '', + )); + }); + } + + QueryBuilder + valuesLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder + valuesIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder + valuesIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder + valuesLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder + valuesLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder + valuesLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } +} + +extension HomeworkCacheModelQueryObject + on QueryBuilder {} + +extension HomeworkCacheModelQueryLinks + on QueryBuilder {} + +extension HomeworkCacheModelQuerySortBy + on QueryBuilder {} + +extension HomeworkCacheModelQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByCacheKey() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheKey', Sort.asc); + }); + } + + QueryBuilder + thenByCacheKeyDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheKey', Sort.desc); + }); + } +} + +extension HomeworkCacheModelQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByValues() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'values'); + }); + } +} + +extension HomeworkCacheModelQueryProperty + on QueryBuilder { + QueryBuilder cacheKeyProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'cacheKey'); + }); + } + + QueryBuilder?, QQueryOperations> + valuesProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'values'); + }); + } +} diff --git a/firka/lib/helpers/db/models/timetable_cache_model.dart b/firka/lib/helpers/db/models/timetable_cache_model.dart new file mode 100644 index 00000000..08834215 --- /dev/null +++ b/firka/lib/helpers/db/models/timetable_cache_model.dart @@ -0,0 +1,29 @@ +import 'package:isar/isar.dart'; + +import '../../debug_helper.dart'; +import '../util.dart'; + +part 'timetable_cache_model.g.dart'; + +@collection +class TimetableCacheModel extends DatedCacheEntry { + TimetableCacheModel(); +} + +Future resetOldTimeTableCache(Isar isar) async { + var now = timeNow(); + var weeks = await isar.timetableCacheModels.where().findAll(); + var weeksToRemove = List.empty(growable: true); + + for (var week in weeks) { + var date = getDate(week.cacheKey!); + + if (date.millisecondsSinceEpoch < + now.subtract(Duration(days: 30)).millisecondsSinceEpoch) { + weeksToRemove.add(week.cacheKey!); + } + } + await isar.writeTxn(() async { + await isar.timetableCacheModels.deleteAll(weeksToRemove); + }); +} diff --git a/firka/lib/helpers/db/models/timetable_cache_model.g.dart b/firka/lib/helpers/db/models/timetable_cache_model.g.dart new file mode 100644 index 00000000..e2874b14 --- /dev/null +++ b/firka/lib/helpers/db/models/timetable_cache_model.g.dart @@ -0,0 +1,562 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_cache_model.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetTimetableCacheModelCollection on Isar { + IsarCollection get timetableCacheModels => + this.collection(); +} + +const TimetableCacheModelSchema = CollectionSchema( + name: r'TimetableCacheModel', + id: -8626340955125680275, + properties: { + r'values': PropertySchema( + id: 0, + name: r'values', + type: IsarType.stringList, + ) + }, + estimateSize: _timetableCacheModelEstimateSize, + serialize: _timetableCacheModelSerialize, + deserialize: _timetableCacheModelDeserialize, + deserializeProp: _timetableCacheModelDeserializeProp, + idName: r'cacheKey', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _timetableCacheModelGetId, + getLinks: _timetableCacheModelGetLinks, + attach: _timetableCacheModelAttach, + version: '3.1.0+1', +); + +int _timetableCacheModelEstimateSize( + TimetableCacheModel object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final list = object.values; + if (list != null) { + bytesCount += 3 + list.length * 3; + { + for (var i = 0; i < list.length; i++) { + final value = list[i]; + bytesCount += value.length * 3; + } + } + } + } + return bytesCount; +} + +void _timetableCacheModelSerialize( + TimetableCacheModel object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeStringList(offsets[0], object.values); +} + +TimetableCacheModel _timetableCacheModelDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = TimetableCacheModel(); + object.cacheKey = id; + object.values = reader.readStringList(offsets[0]); + return object; +} + +P _timetableCacheModelDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringList(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _timetableCacheModelGetId(TimetableCacheModel object) { + return object.cacheKey ?? Isar.autoIncrement; +} + +List> _timetableCacheModelGetLinks( + TimetableCacheModel object) { + return []; +} + +void _timetableCacheModelAttach( + IsarCollection col, Id id, TimetableCacheModel object) { + object.cacheKey = id; +} + +extension TimetableCacheModelQueryWhereSort + on QueryBuilder { + QueryBuilder + anyCacheKey() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension TimetableCacheModelQueryWhere + on QueryBuilder { + QueryBuilder + cacheKeyEqualTo(Id cacheKey) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: cacheKey, + upper: cacheKey, + )); + }); + } + + QueryBuilder + cacheKeyNotEqualTo(Id cacheKey) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: false), + ); + } + }); + } + + QueryBuilder + cacheKeyGreaterThan(Id cacheKey, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: cacheKey, includeLower: include), + ); + }); + } + + QueryBuilder + cacheKeyLessThan(Id cacheKey, {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: cacheKey, includeUpper: include), + ); + }); + } + + QueryBuilder + cacheKeyBetween( + Id lowerCacheKey, + Id upperCacheKey, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerCacheKey, + includeLower: includeLower, + upper: upperCacheKey, + includeUpper: includeUpper, + )); + }); + } +} + +extension TimetableCacheModelQueryFilter on QueryBuilder { + QueryBuilder + cacheKeyIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'cacheKey', + )); + }); + } + + QueryBuilder + cacheKeyIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'cacheKey', + )); + }); + } + + QueryBuilder + cacheKeyEqualTo(Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'cacheKey', + value: value, + )); + }); + } + + QueryBuilder + cacheKeyBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'cacheKey', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder + valuesIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'values', + )); + }); + } + + QueryBuilder + valuesIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'values', + )); + }); + } + + QueryBuilder + valuesElementEqualTo( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementGreaterThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementLessThan( + String value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementBetween( + String lower, + String upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'values', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'values', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'values', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + valuesElementIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'values', + value: '', + )); + }); + } + + QueryBuilder + valuesElementIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'values', + value: '', + )); + }); + } + + QueryBuilder + valuesLengthEqualTo(int length) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + length, + true, + length, + true, + ); + }); + } + + QueryBuilder + valuesIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + 0, + true, + 0, + true, + ); + }); + } + + QueryBuilder + valuesIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + 0, + false, + 999999, + true, + ); + }); + } + + QueryBuilder + valuesLengthLessThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + 0, + true, + length, + include, + ); + }); + } + + QueryBuilder + valuesLengthGreaterThan( + int length, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + length, + include, + 999999, + true, + ); + }); + } + + QueryBuilder + valuesLengthBetween( + int lower, + int upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.listLength( + r'values', + lower, + includeLower, + upper, + includeUpper, + ); + }); + } +} + +extension TimetableCacheModelQueryObject on QueryBuilder {} + +extension TimetableCacheModelQueryLinks on QueryBuilder {} + +extension TimetableCacheModelQuerySortBy + on QueryBuilder {} + +extension TimetableCacheModelQuerySortThenBy + on QueryBuilder { + QueryBuilder + thenByCacheKey() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheKey', Sort.asc); + }); + } + + QueryBuilder + thenByCacheKeyDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'cacheKey', Sort.desc); + }); + } +} + +extension TimetableCacheModelQueryWhereDistinct + on QueryBuilder { + QueryBuilder + distinctByValues() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'values'); + }); + } +} + +extension TimetableCacheModelQueryProperty + on QueryBuilder { + QueryBuilder cacheKeyProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'cacheKey'); + }); + } + + QueryBuilder?, QQueryOperations> + valuesProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'values'); + }); + } +} diff --git a/firka/lib/helpers/db/models/token_model.dart b/firka/lib/helpers/db/models/token_model.dart new file mode 100644 index 00000000..d769a8f6 --- /dev/null +++ b/firka/lib/helpers/db/models/token_model.dart @@ -0,0 +1,69 @@ +/* + Firka, alternative e-Kréta client. + Copyright (C) 2025 QwIT Development + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; +import 'package:firka/helpers/api/resp/token_grant.dart'; +import 'package:isar/isar.dart'; + +import '../../debug_helper.dart'; + +part 'token_model.g.dart'; + +@collection +class TokenModel { + Id? studentId; // Custom unique student identifier + String? iss; // Institution id for student + String? idToken; // Unique identifier for the token if needed + String? accessToken; // The main auth token + String? refreshToken; // Token used to refresh the access token + DateTime? expiryDate; + + TokenModel(); + + factory TokenModel.fromValues(Id studentId, String iss, String idToken, + String accessToken, String refreshToken, int expiryDate) { + var m = TokenModel(); + + m.studentId = studentId; + m.iss = iss; + m.idToken = idToken; + m.accessToken = accessToken; + m.refreshToken = refreshToken; + m.expiryDate = DateTime.fromMillisecondsSinceEpoch(expiryDate); + + return m; + } + + factory TokenModel.fromResp(TokenGrantResponse resp) { + var m = TokenModel(); + final jwt = JWT.decode(resp.idToken); + + // TODO: Add a proper model for jwt id + + m.studentId = int.parse(jwt.payload["kreta:user_name"]); + m.iss = jwt.payload["kreta:institute_code"]; + m.idToken = resp.idToken; + m.accessToken = resp.accessToken; + m.refreshToken = resp.refreshToken; + m.expiryDate = timeNow() + .add(Duration(seconds: resp.expiresIn)) + .subtract(Duration(minutes: 10)); // just to be safe + + return m; + } +} diff --git a/firka/lib/helpers/db/models/token_model.g.dart b/firka/lib/helpers/db/models/token_model.g.dart new file mode 100644 index 00000000..dbe50d69 --- /dev/null +++ b/firka/lib/helpers/db/models/token_model.g.dart @@ -0,0 +1,1206 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'token_model.dart'; + +// ************************************************************************** +// IsarCollectionGenerator +// ************************************************************************** + +// coverage:ignore-file +// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types + +extension GetTokenModelCollection on Isar { + IsarCollection get tokenModels => this.collection(); +} + +const TokenModelSchema = CollectionSchema( + name: r'TokenModel', + id: 6587729607152393036, + properties: { + r'accessToken': PropertySchema( + id: 0, + name: r'accessToken', + type: IsarType.string, + ), + r'expiryDate': PropertySchema( + id: 1, + name: r'expiryDate', + type: IsarType.dateTime, + ), + r'idToken': PropertySchema( + id: 2, + name: r'idToken', + type: IsarType.string, + ), + r'iss': PropertySchema( + id: 3, + name: r'iss', + type: IsarType.string, + ), + r'refreshToken': PropertySchema( + id: 4, + name: r'refreshToken', + type: IsarType.string, + ) + }, + estimateSize: _tokenModelEstimateSize, + serialize: _tokenModelSerialize, + deserialize: _tokenModelDeserialize, + deserializeProp: _tokenModelDeserializeProp, + idName: r'studentId', + indexes: {}, + links: {}, + embeddedSchemas: {}, + getId: _tokenModelGetId, + getLinks: _tokenModelGetLinks, + attach: _tokenModelAttach, + version: '3.1.0+1', +); + +int _tokenModelEstimateSize( + TokenModel object, + List offsets, + Map> allOffsets, +) { + var bytesCount = offsets.last; + { + final value = object.accessToken; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.idToken; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.iss; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + { + final value = object.refreshToken; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } + return bytesCount; +} + +void _tokenModelSerialize( + TokenModel object, + IsarWriter writer, + List offsets, + Map> allOffsets, +) { + writer.writeString(offsets[0], object.accessToken); + writer.writeDateTime(offsets[1], object.expiryDate); + writer.writeString(offsets[2], object.idToken); + writer.writeString(offsets[3], object.iss); + writer.writeString(offsets[4], object.refreshToken); +} + +TokenModel _tokenModelDeserialize( + Id id, + IsarReader reader, + List offsets, + Map> allOffsets, +) { + final object = TokenModel(); + object.accessToken = reader.readStringOrNull(offsets[0]); + object.expiryDate = reader.readDateTimeOrNull(offsets[1]); + object.idToken = reader.readStringOrNull(offsets[2]); + object.iss = reader.readStringOrNull(offsets[3]); + object.refreshToken = reader.readStringOrNull(offsets[4]); + object.studentId = id; + return object; +} + +P _tokenModelDeserializeProp

( + IsarReader reader, + int propertyId, + int offset, + Map> allOffsets, +) { + switch (propertyId) { + case 0: + return (reader.readStringOrNull(offset)) as P; + case 1: + return (reader.readDateTimeOrNull(offset)) as P; + case 2: + return (reader.readStringOrNull(offset)) as P; + case 3: + return (reader.readStringOrNull(offset)) as P; + case 4: + return (reader.readStringOrNull(offset)) as P; + default: + throw IsarError('Unknown property with id $propertyId'); + } +} + +Id _tokenModelGetId(TokenModel object) { + return object.studentId ?? Isar.autoIncrement; +} + +List> _tokenModelGetLinks(TokenModel object) { + return []; +} + +void _tokenModelAttach(IsarCollection col, Id id, TokenModel object) { + object.studentId = id; +} + +extension TokenModelQueryWhereSort + on QueryBuilder { + QueryBuilder anyStudentId() { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(const IdWhereClause.any()); + }); + } +} + +extension TokenModelQueryWhere + on QueryBuilder { + QueryBuilder studentIdEqualTo( + Id studentId) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: studentId, + upper: studentId, + )); + }); + } + + QueryBuilder studentIdNotEqualTo( + Id studentId) { + return QueryBuilder.apply(this, (query) { + if (query.whereSort == Sort.asc) { + return query + .addWhereClause( + IdWhereClause.lessThan(upper: studentId, includeUpper: false), + ) + .addWhereClause( + IdWhereClause.greaterThan(lower: studentId, includeLower: false), + ); + } else { + return query + .addWhereClause( + IdWhereClause.greaterThan(lower: studentId, includeLower: false), + ) + .addWhereClause( + IdWhereClause.lessThan(upper: studentId, includeUpper: false), + ); + } + }); + } + + QueryBuilder studentIdGreaterThan( + Id studentId, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.greaterThan(lower: studentId, includeLower: include), + ); + }); + } + + QueryBuilder studentIdLessThan( + Id studentId, + {bool include = false}) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause( + IdWhereClause.lessThan(upper: studentId, includeUpper: include), + ); + }); + } + + QueryBuilder studentIdBetween( + Id lowerStudentId, + Id upperStudentId, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addWhereClause(IdWhereClause.between( + lower: lowerStudentId, + includeLower: includeLower, + upper: upperStudentId, + includeUpper: includeUpper, + )); + }); + } +} + +extension TokenModelQueryFilter + on QueryBuilder { + QueryBuilder + accessTokenIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'accessToken', + )); + }); + } + + QueryBuilder + accessTokenIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'accessToken', + )); + }); + } + + QueryBuilder + accessTokenEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'accessToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + accessTokenGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'accessToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + accessTokenLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'accessToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + accessTokenBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'accessToken', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + accessTokenStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'accessToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + accessTokenEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'accessToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + accessTokenContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'accessToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + accessTokenMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'accessToken', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + accessTokenIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'accessToken', + value: '', + )); + }); + } + + QueryBuilder + accessTokenIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'accessToken', + value: '', + )); + }); + } + + QueryBuilder + expiryDateIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'expiryDate', + )); + }); + } + + QueryBuilder + expiryDateIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'expiryDate', + )); + }); + } + + QueryBuilder expiryDateEqualTo( + DateTime? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'expiryDate', + value: value, + )); + }); + } + + QueryBuilder + expiryDateGreaterThan( + DateTime? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'expiryDate', + value: value, + )); + }); + } + + QueryBuilder + expiryDateLessThan( + DateTime? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'expiryDate', + value: value, + )); + }); + } + + QueryBuilder expiryDateBetween( + DateTime? lower, + DateTime? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'expiryDate', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + + QueryBuilder idTokenIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'idToken', + )); + }); + } + + QueryBuilder + idTokenIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'idToken', + )); + }); + } + + QueryBuilder idTokenEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'idToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + idTokenGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'idToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder idTokenLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'idToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder idTokenBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'idToken', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder idTokenStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'idToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder idTokenEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'idToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder idTokenContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'idToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder idTokenMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'idToken', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder idTokenIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'idToken', + value: '', + )); + }); + } + + QueryBuilder + idTokenIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'idToken', + value: '', + )); + }); + } + + QueryBuilder issIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'iss', + )); + }); + } + + QueryBuilder issIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'iss', + )); + }); + } + + QueryBuilder issEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'iss', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder issGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'iss', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder issLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'iss', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder issBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'iss', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder issStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'iss', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder issEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'iss', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder issContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'iss', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder issMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'iss', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder issIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'iss', + value: '', + )); + }); + } + + QueryBuilder issIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'iss', + value: '', + )); + }); + } + + QueryBuilder + refreshTokenIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'refreshToken', + )); + }); + } + + QueryBuilder + refreshTokenIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'refreshToken', + )); + }); + } + + QueryBuilder + refreshTokenEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'refreshToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + refreshTokenGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'refreshToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + refreshTokenLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'refreshToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + refreshTokenBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'refreshToken', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + refreshTokenStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'refreshToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + refreshTokenEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'refreshToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + refreshTokenContains(String value, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'refreshToken', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + refreshTokenMatches(String pattern, {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'refreshToken', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder + refreshTokenIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'refreshToken', + value: '', + )); + }); + } + + QueryBuilder + refreshTokenIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'refreshToken', + value: '', + )); + }); + } + + QueryBuilder + studentIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'studentId', + )); + }); + } + + QueryBuilder + studentIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'studentId', + )); + }); + } + + QueryBuilder studentIdEqualTo( + Id? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'studentId', + value: value, + )); + }); + } + + QueryBuilder + studentIdGreaterThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'studentId', + value: value, + )); + }); + } + + QueryBuilder studentIdLessThan( + Id? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'studentId', + value: value, + )); + }); + } + + QueryBuilder studentIdBetween( + Id? lower, + Id? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'studentId', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } +} + +extension TokenModelQueryObject + on QueryBuilder {} + +extension TokenModelQueryLinks + on QueryBuilder {} + +extension TokenModelQuerySortBy + on QueryBuilder { + QueryBuilder sortByAccessToken() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accessToken', Sort.asc); + }); + } + + QueryBuilder sortByAccessTokenDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accessToken', Sort.desc); + }); + } + + QueryBuilder sortByExpiryDate() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'expiryDate', Sort.asc); + }); + } + + QueryBuilder sortByExpiryDateDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'expiryDate', Sort.desc); + }); + } + + QueryBuilder sortByIdToken() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'idToken', Sort.asc); + }); + } + + QueryBuilder sortByIdTokenDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'idToken', Sort.desc); + }); + } + + QueryBuilder sortByIss() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'iss', Sort.asc); + }); + } + + QueryBuilder sortByIssDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'iss', Sort.desc); + }); + } + + QueryBuilder sortByRefreshToken() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'refreshToken', Sort.asc); + }); + } + + QueryBuilder sortByRefreshTokenDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'refreshToken', Sort.desc); + }); + } +} + +extension TokenModelQuerySortThenBy + on QueryBuilder { + QueryBuilder thenByAccessToken() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accessToken', Sort.asc); + }); + } + + QueryBuilder thenByAccessTokenDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'accessToken', Sort.desc); + }); + } + + QueryBuilder thenByExpiryDate() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'expiryDate', Sort.asc); + }); + } + + QueryBuilder thenByExpiryDateDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'expiryDate', Sort.desc); + }); + } + + QueryBuilder thenByIdToken() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'idToken', Sort.asc); + }); + } + + QueryBuilder thenByIdTokenDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'idToken', Sort.desc); + }); + } + + QueryBuilder thenByIss() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'iss', Sort.asc); + }); + } + + QueryBuilder thenByIssDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'iss', Sort.desc); + }); + } + + QueryBuilder thenByRefreshToken() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'refreshToken', Sort.asc); + }); + } + + QueryBuilder thenByRefreshTokenDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'refreshToken', Sort.desc); + }); + } + + QueryBuilder thenByStudentId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'studentId', Sort.asc); + }); + } + + QueryBuilder thenByStudentIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'studentId', Sort.desc); + }); + } +} + +extension TokenModelQueryWhereDistinct + on QueryBuilder { + QueryBuilder distinctByAccessToken( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'accessToken', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByExpiryDate() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'expiryDate'); + }); + } + + QueryBuilder distinctByIdToken( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'idToken', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByIss( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'iss', caseSensitive: caseSensitive); + }); + } + + QueryBuilder distinctByRefreshToken( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'refreshToken', caseSensitive: caseSensitive); + }); + } +} + +extension TokenModelQueryProperty + on QueryBuilder { + QueryBuilder studentIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'studentId'); + }); + } + + QueryBuilder accessTokenProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'accessToken'); + }); + } + + QueryBuilder expiryDateProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'expiryDate'); + }); + } + + QueryBuilder idTokenProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'idToken'); + }); + } + + QueryBuilder issProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'iss'); + }); + } + + QueryBuilder refreshTokenProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'refreshToken'); + }); + } +} diff --git a/firka/lib/helpers/db/util.dart b/firka/lib/helpers/db/util.dart new file mode 100644 index 00000000..455099be --- /dev/null +++ b/firka/lib/helpers/db/util.dart @@ -0,0 +1,26 @@ +import 'dart:math'; + +import 'package:intl/intl.dart'; +import 'package:isar/isar.dart'; + +import '../debug_helper.dart'; + +class DatedCacheEntry { + Id? cacheKey; + List? values; +} + +int genCacheKey(DateTime date, int studentId) { + var md = date.month * pow(10, 2) + date.day; + + return (md * pow(10, 11) + studentId) as int; +} + +DateTime getDate(int key) { + var currentDate = timeNow(); + var md = key ~/ pow(10, 11); + var month = md ~/ pow(10, 2); + var day = md - month * pow(10, 2); + + return DateFormat("yyyy-M-d").parse("${currentDate.year}-$month-$day"); +} diff --git a/firka/lib/helpers/debug_helper.dart b/firka/lib/helpers/debug_helper.dart new file mode 100644 index 00000000..d0c04a0d --- /dev/null +++ b/firka/lib/helpers/debug_helper.dart @@ -0,0 +1,17 @@ +DateTime? debugFakeTime; +DateTime? debugSetAt; +var debugTimeAdvance = false; + +DateTime timeNow() { + if (debugFakeTime != null) { + if (debugTimeAdvance && debugSetAt != null) { + var diff = DateTime.now().difference(debugSetAt!); + + return debugFakeTime!.add(diff); + } else { + return debugFakeTime!; + } + } else { + return DateTime.now(); + } +} diff --git a/firka/lib/helpers/extensions.dart b/firka/lib/helpers/extensions.dart new file mode 100644 index 00000000..26b487f9 --- /dev/null +++ b/firka/lib/helpers/extensions.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../l10n/app_localizations.dart'; +import 'api/model/timetable.dart'; +import 'debug_helper.dart'; + +extension IterableExtensionMap on Iterable> { + Map toMap() { + var map = {}; + for (var item in this) { + map[item.key] = item.value; + } + + return map; + } +} + +extension IterableExtension on Iterable { + T? firstWhereOrNull(bool Function(T element) test) { + for (var element in this) { + if (test(element)) return element; + } + return null; + } +} + +extension DurationExtension on Duration { + String formatDuration() { + String hours = inHours.toString().padLeft(2, '0'); + String minutes = inMinutes.remainder(60).toString().padLeft(2, '0'); + String seconds = inSeconds.remainder(60).toString().padLeft(2, '0'); + return "$hours:$minutes:$seconds"; + } +} + +enum FormatMode { yearly, grades, welcome, hmm, da, dd } + +enum Cycle { morning, day, afternoon, night } + +extension DateExtension on DateTime { + String format(BuildContext context, FormatMode mode) { + var today = timeNow(); + today = today.subtract(Duration( + hours: today.hour, + minutes: today.minute, + seconds: today.second, + milliseconds: today.millisecond)); + + var tomorrowLim = today.add(Duration(days: 2)); + var tomorrow = today.add(Duration(days: 1)); + var yesterday = today.subtract(Duration(days: 1)); + var yesterdayLim = today.subtract(Duration(days: 2)); + + switch (mode) { + case FormatMode.grades: + if (isBefore(yesterdayLim)) { + return format(context, FormatMode.yearly); + } + if (isAfter(yesterdayLim) && isBefore(today)) { + return AppLocalizations.of(context)!.yesterday; + } + if (isAfter(yesterday) && isBefore(tomorrow)) { + return AppLocalizations.of(context)!.today; + } + if (isAfter(today) && isBefore(tomorrowLim)) { + return AppLocalizations.of(context)!.tomorrow; + } + + return format(context, FormatMode.yearly); + case FormatMode.yearly: + return DateFormat('MMMM dd').format(this); + case FormatMode.hmm: + return DateFormat('H:mm').format(this); + case FormatMode.welcome: + return DateFormat('EEE, MMM d').format(this); + case FormatMode.da: + return DateFormat('MMMMEEEEd').format(this).substring(0, 2); + case FormatMode.dd: + return DateFormat('dd').format(this); + } + } + + DateTime getMonday() { + return subtract(Duration(days: weekday - 1)); + } + + DateTime getMidnight() { + return subtract(Duration( + hours: hour, + minutes: minute, + seconds: second, + milliseconds: millisecond)); + } + + Cycle getDayCycle() { + var midnight = getMidnight(); + if (isAfter(midnight.add(Duration(hours: 5, minutes: 30))) && + isBefore(midnight.add(Duration(hours: 9)))) { + return Cycle.morning; + } + if (isAfter(midnight.add(Duration(hours: 5, minutes: 30))) && + isBefore(midnight.add(Duration(hours: 14)))) { + return Cycle.day; + } + if (isAfter(midnight.add(Duration(hours: 5, minutes: 30))) && + isBefore(midnight.add(Duration(hours: 20)))) { + return Cycle.afternoon; + } + + return Cycle.night; + } +} + +extension DateGrouper on Iterable { + Map> groupList(DateTime Function(T elem) getDate) { + Map> newList = {}; + + var today = timeNow(); + today = today.subtract(Duration( + hours: today.hour, + minutes: today.minute, + seconds: today.second, + milliseconds: today.millisecond)); + + var tomorrow = today.add(Duration(days: 1)); + var yesterday = today.subtract(Duration(days: 1)); + + for (var elem in this) { + var date = getDate(elem); + var day = date.subtract(Duration( + hours: date.hour, + minutes: date.minute, + seconds: date.second, + milliseconds: date.millisecond)); + + if (date.isAfter(tomorrow.add(Duration(days: 1)))) { + if (newList[day] == null) { + newList[day] = List.empty(growable: true); + } + + newList[day]!.add(elem); + continue; + } + if (date.isAfter(today)) { + if (newList[tomorrow] == null) { + newList[tomorrow] = List.empty(growable: true); + } + + newList[tomorrow]!.add(elem); + continue; + } + if (date.isAfter(yesterday.subtract(Duration(days: 1))) && + date.isBefore(today)) { + if (newList[yesterday] == null) { + newList[yesterday] = List.empty(growable: true); + } + + newList[yesterday]!.add(elem); + continue; + } + + if (newList[day] == null) { + newList[day] = List.empty(growable: true); + } + + newList[day]!.add(elem); + } + + return newList; + } +} + +extension LessonExtension on List { + int getLessonNo(Lesson lesson) { + return lesson.lessonNumber ?? indexOf(lesson); + } + + Lesson? getCurrentLesson(DateTime now) { + return firstWhereOrNull( + (lesson) => now.isAfter(lesson.start) && now.isBefore(lesson.end)); + } + + Lesson? getPrevLesson(DateTime now) { + return firstWhereOrNull( + (lesson) => lesson.end.isBefore(now.add(Duration(milliseconds: 1)))); + } + + Lesson? getNextLesson(DateTime now) { + return firstWhereOrNull( + (lesson) => lesson.start.isAfter(now.add(Duration(milliseconds: 1)))); + } +} diff --git a/firka/lib/helpers/icon_helper.dart b/firka/lib/helpers/icon_helper.dart new file mode 100644 index 00000000..6221f33a --- /dev/null +++ b/firka/lib/helpers/icon_helper.dart @@ -0,0 +1,148 @@ +import 'dart:typed_data'; + +import 'package:majesticons_flutter/majesticons_flutter.dart'; + +enum ClassIcon { + mathematics, + grammar, + literature, + history, + geography, + art, + physics, + music, + pe, + chemistry, + biology, + env, + religion, + economics, + it, + code, + networking, + theatre, + film, + electricalEngineering, + mechanicalEngineering, + technika, + dance, + philosophy, + ofo, + diligence, + attitude, + language, + linux, + database, + applications, + project +} + +Map _descriptors = { + ClassIcon.mathematics: RegExp(r'mate(k|matika)'), + ClassIcon.grammar: RegExp(r'magyar nyelv|nyelvtan'), + ClassIcon.literature: RegExp(r'irodalom'), + ClassIcon.history: RegExp(r'tor(i|tenelem)'), + ClassIcon.geography: RegExp(r'foldrajz'), + ClassIcon.art: RegExp(r'rajz|muvtori|muveszet|vizualis'), + ClassIcon.physics: RegExp(r'fizika'), + ClassIcon.music: RegExp(r'^enek|zene|szolfezs|zongora|korus'), + ClassIcon.pe: RegExp(r'^tes(i|tneveles)|sport|edzeselmelet'), + ClassIcon.chemistry: RegExp(r'kemia'), + ClassIcon.biology: RegExp(r'biologia'), + ClassIcon.env: + RegExp(r'kornyezet|termeszet ?(tudomany|ismeret)|hon( es nep)?ismeret'), + ClassIcon.religion: RegExp(r'(hit|erkolcs)tan|vallas|etika|bibliaismeret'), + ClassIcon.economics: RegExp(r'penzugy|gazdasag'), + ClassIcon.it: RegExp(r'informatika|szoftver|iroda|digitalis'), + ClassIcon.code: RegExp(r'prog|alkalmazas'), + ClassIcon.networking: RegExp(r'halozat'), + ClassIcon.theatre: RegExp(r'szinhaz'), + ClassIcon.film: RegExp(r'film|media'), + ClassIcon.electricalEngineering: RegExp(r'elektro(tech)?nika'), + ClassIcon.mechanicalEngineering: RegExp(r'gepesz|mernok|ipar'), + ClassIcon.technika: RegExp(r'technika'), + ClassIcon.dance: RegExp(r'tanc'), + ClassIcon.philosophy: RegExp(r'filozofia'), + ClassIcon.ofo: RegExp(r'osztaly(fonoki|kozosseg)|kozossegi|neveles'), + ClassIcon.diligence: RegExp(r'szorgalom'), + ClassIcon.attitude: RegExp(r'magatartas'), + ClassIcon.language: + RegExp(r'angol|nemet|francia|olasz|orosz|spanyol|latin|kinai|nyelv'), + ClassIcon.linux: RegExp(r'linux'), + ClassIcon.database: RegExp(r'adatbazis.*'), + ClassIcon.applications: RegExp(r'asztali alkalmazasok'), + ClassIcon.project: RegExp(r'projekt') +}; + +Map _iconMap = { + ClassIcon.mathematics: Majesticon.calculatorSolid, + ClassIcon.grammar: Majesticon.bookSolid, + ClassIcon.literature: Majesticon.bookOpenSolid, + ClassIcon.history: Majesticon.compass2Solid, + ClassIcon.geography: Majesticon.globeEarth2Solid, + ClassIcon.art: Majesticon.editPen2Solid, + // ClassIcon.physics: , + ClassIcon.music: Majesticon.musicNoteSolid, + // ClassIcon.pe: , + ClassIcon.chemistry: Majesticon.testTubeFilledSolid, + ClassIcon.biology: Majesticon.covidSolid, + // ClassIcon.env: , + // ClassIcon.religion: , + // ClassIcon.economics: , + ClassIcon.it: Majesticon.laptopSolid, + ClassIcon.code: Majesticon.curlyBracesSolid, + ClassIcon.networking: Majesticon.cloudSolid, + // ClassIcon.theatre: , + // ClassIcon.film: , + // ClassIcon.electricalEngineering: , + // ClassIcon.mechanicalEngineering: , + ClassIcon.technika: Majesticon.ruler2Solid, + // ClassIcon.dance: , + // ClassIcon.philosophy: , + // ClassIcon.ofo: , + // ClassIcon.diligence: , + // ClassIcon.attitude: , + ClassIcon.language: Majesticon.tooltipsSolid, + // ClassIcon.linux: , + ClassIcon.database: Majesticon.dataSolid, + // ClassIcon.applications: , + // ClassIcon.project: , +}; + +ClassIcon? getIconType(String uid, String className, String category) { + ClassIcon? icon; + if (category.toLowerCase() == "matematika") { + icon = ClassIcon.mathematics; + } + + if (icon == null) { + for (var desc in _descriptors.entries) { + if (desc.value.hasMatch(className + .replaceAll("ö", "o") + .replaceAll("ü", "u") + .replaceAll("ó", "o") + .replaceAll("ő", "o") + .replaceAll("ú", "u") + .replaceAll("é", "e") + .replaceAll("á", "a") + .replaceAll("ű", "u") + .replaceAll("í", "i") + .toLowerCase())) { + icon = desc.key; + + break; + } + } + } + + return icon; +} + +Uint8List getIconData(ClassIcon? icon) { + if (icon == null) return Majesticon.alertCircleSolid; + + var iconData = _iconMap[icon]; + iconData ??= Majesticon.alertCircleSolid; + + return iconData; +} diff --git a/firka/lib/helpers/json_helper.dart b/firka/lib/helpers/json_helper.dart new file mode 100644 index 00000000..4e077134 --- /dev/null +++ b/firka/lib/helpers/json_helper.dart @@ -0,0 +1,9 @@ +List listToTyped(List dynamicList) { + var newList = List.empty(growable: true); + + for (var item in dynamicList) { + newList.add(item as T); + } + + return newList; +} diff --git a/firka/lib/helpers/profile_picture.dart b/firka/lib/helpers/profile_picture.dart new file mode 100644 index 00000000..bad898b8 --- /dev/null +++ b/firka/lib/helpers/profile_picture.dart @@ -0,0 +1,22 @@ +import 'dart:io'; + +import 'package:firka/main.dart'; +import 'package:image/image.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +Future pickProfilePicture( + AppInitialization data, ImagePicker picker) async { + var imageFile = await picker.pickImage(source: ImageSource.gallery); + if (imageFile == null) return; + + var image = await decodeImageFile(imageFile.path); + var resized = copyResize(image!, width: 128, maintainAspect: true); + + var dataDir = await getApplicationDocumentsDirectory(); + var bytes = encodePng(resized); + await File(p.join(dataDir.path, "profile.png")).writeAsBytes(bytes); + + data.profilePicture = bytes; +} diff --git a/firka/lib/helpers/ui/firka_card.dart b/firka/lib/helpers/ui/firka_card.dart new file mode 100644 index 00000000..e27c867e --- /dev/null +++ b/firka/lib/helpers/ui/firka_card.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import '../../ui/model/style.dart'; + +class FirkaCard extends StatelessWidget { + final List left; + final List? right; + final Widget? extra; + + const FirkaCard({required this.left, this.right, this.extra, super.key}); + + @override + Widget build(BuildContext context) { + var right = this.right ?? []; + + if (extra != null) { + return SizedBox( + width: MediaQuery.of(context).size.width, + child: Card( + color: appStyle.colors.card, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: left), + Row(children: right), + ], + ), + extra ?? SizedBox(), + ], + ), + ), + ), + ); + } else { + return SizedBox( + width: MediaQuery.of(context).size.width, + child: Card( + color: appStyle.colors.card, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: left), + Row(children: right), + ], + ), + ), + ), + ); + } + } +} diff --git a/firka/lib/helpers/ui/grade.dart b/firka/lib/helpers/ui/grade.dart new file mode 100644 index 00000000..231dac8a --- /dev/null +++ b/firka/lib/helpers/ui/grade.dart @@ -0,0 +1,70 @@ +import 'package:firka/helpers/api/model/grade.dart'; +import 'package:flutter/material.dart'; + +import '../../ui/model/style.dart'; +import 'grade_helpers.dart'; + +class GradeWidget extends StatelessWidget { + final Grade grade; + + const GradeWidget(this.grade, {super.key}); + + @override + Widget build(BuildContext context) { + Color gradeColor = appStyle.colors.grade1; + var gradeStr = grade.numericValue?.toString() ?? "0"; + double eccentricity = 0; + + if (grade.valueType.name == "Szazalekos") { + gradeStr = grade.strValue.replaceAll("%", ""); + if (grade.numericValue != null) { + gradeColor = + getGradeColor(percentageToGrade(grade.numericValue!).toDouble()); + } + + if (grade.numericValue != null && grade.numericValue == 100) { + return Card( + shape: CircleBorder(eccentricity: eccentricity), + shadowColor: Colors.transparent, + color: gradeColor.withAlpha(38), + child: Padding( + padding: EdgeInsets.only(left: 8, right: 8), + child: Row(children: [ + Text("100", // TODO: Make this curved + style: appStyle.fonts.P_14.copyWith(color: gradeColor)) + ]), + ), + ); + } else { + return Card( + shape: CircleBorder(eccentricity: eccentricity), + shadowColor: Colors.transparent, + color: gradeColor.withAlpha(38), + child: Padding( + padding: EdgeInsets.only(left: 8, right: 8), + child: Row(children: [ + Text(gradeStr, + style: appStyle.fonts.P_14.copyWith(color: gradeColor)), + Text("%", style: appStyle.fonts.P_12.copyWith(color: gradeColor)) + ]), + ), + ); + } + } else { + if (grade.numericValue != null) { + gradeColor = getGradeColor(grade.numericValue!.toDouble()); + } + + return Card( + shape: CircleBorder(eccentricity: eccentricity), + shadowColor: Colors.transparent, + color: gradeColor.withAlpha(38), + child: Padding( + padding: EdgeInsets.only(left: 8, right: 8), + child: Text(gradeStr, + style: appStyle.fonts.H_H1 + .copyWith(fontSize: 24, color: gradeColor))), + ); + } + } +} diff --git a/firka/lib/helpers/ui/grade_helpers.dart b/firka/lib/helpers/ui/grade_helpers.dart new file mode 100644 index 00000000..89e3ce03 --- /dev/null +++ b/firka/lib/helpers/ui/grade_helpers.dart @@ -0,0 +1,74 @@ +import 'dart:ui'; + +import '../../ui/model/style.dart'; +import '../api/model/grade.dart'; +import '../api/model/subject.dart'; + +int roundGrade(double grade) { + if (grade < 2) { + return 1; + } + if (grade < 2.5) { + return 2; + } + if (grade < 3.5) { + return 3; + } + if (grade < 4.5) { + return 4; + } + + return 5; +} + +int percentageToGrade(int grade) { + if (grade < 50) { + return 1; + } + if (grade < 60) { + return 2; + } + if (grade < 70) { + return 3; + } + if (grade < 80) { + return 4; + } + + return 5; +} + +Color getGradeColor(double grade) { + switch (roundGrade(grade)) { + case 2: + return appStyle.colors.grade2; + case 3: + return appStyle.colors.grade3; + case 4: + return appStyle.colors.grade4; + case 5: + return appStyle.colors.grade5; + default: + return appStyle.colors.grade1; + } +} + +extension GradeListExtension on List { + double getAverageBySubject(Subject subject) { + var weightTotal = 0.00; + var sum = 0.00; + + for (var grade in this) { + if (grade.subject.uid == subject.uid) { + if (grade.numericValue != null) { + var weight = (grade.weightPercentage ?? 100) / 100.0; + weightTotal += weight; + + sum += grade.numericValue! * weight; + } + } + } + + return sum / weightTotal; + } +} diff --git a/firka/lib/helpers/ui/stateless_async_widget.dart b/firka/lib/helpers/ui/stateless_async_widget.dart new file mode 100644 index 00000000..f48818f6 --- /dev/null +++ b/firka/lib/helpers/ui/stateless_async_widget.dart @@ -0,0 +1,23 @@ +import 'package:firka/ui/widget/delayed_spinner.dart'; +import 'package:flutter/material.dart'; + +abstract class StatelessAsyncWidget extends StatelessWidget { + const StatelessAsyncWidget({super.key}); + + Future buildAsync(BuildContext context); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: buildAsync(context), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Center(child: DelayedSpinnerWidget()); + } else if (snapshot.hasError) { + return Center(child: Text('Error: ${snapshot.error}')); + } else { + return snapshot.data!; + } + }); + } +} diff --git a/firka/lib/l10n b/firka/lib/l10n new file mode 160000 index 00000000..da563351 --- /dev/null +++ b/firka/lib/l10n @@ -0,0 +1 @@ +Subproject commit da563351c82331012d5c6309655145124da2bc8e diff --git a/firka/lib/main.dart b/firka/lib/main.dart new file mode 100644 index 00000000..25e02d82 --- /dev/null +++ b/firka/lib/main.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:firka/helpers/api/client/kreta_client.dart'; +import 'package:firka/helpers/db/models/app_settings_model.dart'; +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/helpers/extensions.dart'; +import 'package:firka/ui/phone/pages/error/error_page.dart'; +import 'package:firka/ui/phone/screens/debug/debug_screen.dart'; +import 'package:firka/ui/phone/screens/home/home_screen.dart'; +import 'package:firka/ui/phone/screens/login/login_screen.dart'; +import 'package:firka/ui/phone/screens/wear_login/wear_login_screen.dart'; +import 'package:firka/wear_main.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:isar/isar.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:watch_connectivity/watch_connectivity.dart'; + +import 'helpers/api/consts.dart'; +import 'helpers/db/models/homework_cache_model.dart'; +import 'l10n/app_localizations.dart'; + +Isar? isarInit; +final GlobalKey navigatorKey = GlobalKey(); +late AppInitialization initData; + +final dio = Dio(); + +class AppInitialization { + final Isar isar; + late KretaClient client; + int tokenCount; + bool hasWatchListener = false; + Uint8List? profilePicture; + AppSettingsModel settings; + + AppInitialization({ + required this.isar, + required this.tokenCount, + required this.settings, + }); + + bool _writing = false; + + Future saveSettings() async { + while (_writing) { + await Future.delayed(const Duration(milliseconds: 50)); + } + _writing = true; + await isar.writeTxn(() async { + await isar.appSettingsModels.put(settings); + }); + _writing = false; + } +} + +Future initDB() async { + if (isarInit != null) return isarInit!; + final dir = await getApplicationDocumentsDirectory(); + + isarInit = await Isar.open( + [ + TokenModelSchema, + GenericCacheModelSchema, + TimetableCacheModelSchema, + HomeworkCacheModelSchema, + AppSettingsModelSchema, + ], + inspector: true, + directory: dir.path, + ); + + return isarInit!; +} + +Future initializeApp() async { + final isar = await initDB(); + final tokenCount = await isar.tokenModels.count(); + var settings = AppSettingsModel(); + settings.id = 0; + + if (kDebugMode) { + print('Token count: $tokenCount'); + } + + if (await isar.appSettingsModels.count() != 0) { + settings = (await isar.appSettingsModels.where().findFirst())!; + } + + if (settings.useCustomHost != null && settings.useCustomHost!) { + var host = settings.customHost!; + + KretaEndpoints.kretaBase = "https://$host"; + KretaEndpoints.kretaIdp = KretaEndpoints.kretaBase; + KretaEndpoints.kretaLoginUrl = + "${KretaEndpoints.kretaBase}/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.kretaBase}/connect/token"; + } + + var init = AppInitialization( + isar: isar, + tokenCount: tokenCount, + settings: settings, + ); + + resetOldTimeTableCache(isar); + resetOldHomeworkCache(isar); + + // TODO: Account selection + if (tokenCount > 0) { + init.client = + KretaClient((await isar.tokenModels.where().findFirst())!, isar); + } + + final dataDir = await getApplicationDocumentsDirectory(); + var pfpFile = File(p.join(dataDir.path, "profile.png")); + + if (await pfpFile.exists()) { + init.profilePicture = await pfpFile.readAsBytes(); + } + + return init; +} + +void main() async { + dio.options.connectTimeout = Duration(seconds: 5); + dio.options.receiveTimeout = Duration(seconds: 3); + + WidgetsFlutterBinding.ensureInitialized(); + const platform = MethodChannel('firka.app/main'); + if (Platform.isAndroid) { + var isWear = (await platform.invokeMethod("isWear")) as bool; + + if (isWear) { + wearMain(platform); + return; + } + } + + runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Run App Initialization + runApp(InitializationScreen()); + }, (error, stackTrace) { + debugPrint('Caught error: $error'); + debugPrint('Stack trace: $stackTrace'); + + navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (context) => + ErrorPage(key: ValueKey('errorPage'), exception: error.toString()), + ), + ); + }); +} + +class InitializationScreen extends StatelessWidget { + InitializationScreen({super.key}); + + // Place to store the initialization future + final Future _initialization = initializeApp(); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _initialization, + builder: (context, snapshot) { + // Check if initialization is complete + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + debugPrintStack(label: snapshot.error.toString()); + + // Handle initialization error + return MaterialApp( + key: ValueKey('errorPage'), + home: Scaffold( + body: Center( + child: Text( + 'Error initializing app: ${snapshot.error}', + style: TextStyle(color: Colors.red), + ), + ), + ), + ); + } + + // Initialization successful, determine which screen to show + Widget screen; + + assert(snapshot.data != null); + initData = snapshot.data!; + var watch = WatchConnectivity(); + + if (!initData.hasWatchListener) { + initData.hasWatchListener = true; + + watch.messageStream.listen((e) { + var msg = e.entries.toMap(); + + debugPrint("[Watch -> Phone]: ${msg["id"]}"); + + switch (msg["id"]) { + case "ping": + debugPrint("[Phone -> Watch]: pong"); + watch.sendMessage({"id": "pong"}); + navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (context) => WearLoginScreen(initData), + ), + ); + } + }); + } + + if (snapshot.data!.tokenCount == 0) { + screen = LoginScreen( + initData, + key: ValueKey('loginScreen'), + ); + } else { + screen = HomeScreen( + initData, + key: ValueKey('homeScreen'), + ); + } + + return MaterialApp( + title: 'Firka', + key: ValueKey('firkaApp'), + navigatorKey: navigatorKey, + // Use the global navigator key + theme: ThemeData( + primarySwatch: Colors.lightGreen, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + localizationsDelegates: [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: screen, + routes: { + '/login': (context) => LoginScreen( + initData, + key: ValueKey('loginScreen'), + ), + '/debug': (context) => DebugScreen( + initData, + key: ValueKey('debugScreen'), + ), + }, + ); + } + + return MaterialApp( + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + color: const Color(0xFF7CA021), + ) + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/firka/lib/ui/model/style.dart b/firka/lib/ui/model/style.dart new file mode 100644 index 00000000..5bee8988 --- /dev/null +++ b/firka/lib/ui/model/style.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; + +class FirkaFonts { + TextStyle H_H1; + TextStyle H_18px; + TextStyle H_H2; + TextStyle H_16px; + TextStyle H_14px; + TextStyle H_12px; + + TextStyle H_16px_trimmed; // TODO: somehow implement this + // the design has this trimmed to 130% line height + + TextStyle B_16R; + TextStyle B_16SB; + + TextStyle B_14R; + TextStyle B_14SB; + + TextStyle B_12R; + TextStyle B_12SB; + + TextStyle P_14; + TextStyle P_12; + + FirkaFonts({ + required this.H_H1, + required this.H_18px, + required this.H_H2, + required this.H_16px, + required this.H_14px, + required this.H_12px, + required this.H_16px_trimmed, + required this.B_16R, + required this.B_16SB, + required this.B_14R, + required this.B_14SB, + required this.B_12R, + required this.B_12SB, + required this.P_14, + required this.P_12, + }); +} + +class FirkaColors { + Color background; + Color backgroundAmoled; + Color background0p; + Color success; + int shadowBlur; + + Color textPrimary; + Color textSecondary; + Color textTertiary; + + Color card; + Color cardTranslucent; + + Color buttonSecondaryFill; + + Color accent; + Color secondary; + Color shadowColor; + Color a15p; // 15% + + Color warningAccent; + Color warningText; + Color warning15p; + Color warningCard; + + Color errorAccent; + Color errorText; + Color error15p; + Color errorCard; + + Color grade5; + Color grade4; + Color grade3; + Color grade2; + Color grade1; + + FirkaColors({ + required this.background, + required this.backgroundAmoled, + required this.background0p, + required this.success, + required this.shadowBlur, + required this.textPrimary, + required this.textSecondary, + required this.textTertiary, + required this.card, + required this.cardTranslucent, + required this.buttonSecondaryFill, + required this.accent, + required this.secondary, + required this.shadowColor, + required this.a15p, + required this.warningAccent, + required this.warningText, + required this.warning15p, + required this.warningCard, + required this.errorAccent, + required this.errorText, + required this.error15p, + required this.errorCard, + required this.grade5, + required this.grade4, + required this.grade3, + required this.grade2, + required this.grade1, + }); +} + +class FirkaStyle { + FirkaColors colors; + FirkaFonts fonts; + + FirkaStyle({required this.colors, required this.fonts}); +} + +final _defaultFonts = FirkaFonts( + H_H1: TextStyle( + fontSize: 30, + fontFamily: 'Montserrat', + fontVariations: [FontVariation("wght", 700)], + ), + H_18px: TextStyle( + fontSize: 18, + fontFamily: 'Montserrat', + fontVariations: [FontVariation("wght", 700)], + ), + H_H2: TextStyle( + fontSize: 20, + fontFamily: 'Montserrat', + fontVariations: [FontVariation("wght", 700)], + ), + H_16px: TextStyle( + fontSize: 16, + fontFamily: 'Montserrat', + fontVariations: [FontVariation("wght", 600)], + ), + H_14px: TextStyle( + fontSize: 14, + fontFamily: 'Montserrat', + fontVariations: [FontVariation("wght", 600)], + ), + H_12px: TextStyle( + fontSize: 12, + fontFamily: 'Montserrat', + fontVariations: [FontVariation("wght", 600)], + ), + H_16px_trimmed: TextStyle( + fontSize: 16, + fontFamily: 'Montserrat', + fontVariations: [FontVariation("wght", 600)], + ), + B_16R: TextStyle( + fontSize: 16, + fontFamily: 'Figtree', + fontVariations: [FontVariation("wght", 600)]), + B_16SB: TextStyle( + fontSize: 16, + fontFamily: 'Figtree', + fontVariations: [FontVariation("wght", 700)], + ), + B_14R: TextStyle( + fontSize: 14, + fontFamily: 'Figtree', + fontVariations: [FontVariation("wght", 600)]), + B_14SB: TextStyle( + fontSize: 14, + fontFamily: 'Figtree', + fontVariations: [FontVariation("wght", 700)], + ), + B_12R: TextStyle( + fontSize: 12, + fontFamily: 'Figtree', + fontVariations: [FontVariation("wght", 600)]), + B_12SB: TextStyle( + fontSize: 12, + fontFamily: 'Figtree', + fontVariations: [FontVariation("wght", 700)], + ), + P_14: TextStyle( + fontSize: 14, + fontFamily: 'RobotoMono', + fontVariations: [FontVariation("wght", 700)], + ), + P_12: TextStyle( + fontSize: 12, + fontFamily: 'RobotoMono', + fontVariations: [FontVariation("wght", 700)], + ), +); + +final FirkaStyle lightStyle = FirkaStyle( + colors: FirkaColors( + background: Color(0xFFFAFFF0), + backgroundAmoled: Colors.black, + background0p: Color(0x00fafff0), + success: Color(0xFF92EA3B), + shadowBlur: 2, + textPrimary: Color(0xFF394C0A), + textSecondary: Color(0xCC394C0A), + textTertiary: Color(0x80394C0A), + card: Color(0xFFF3FBDE), + cardTranslucent: Color(0x80F3FBDE), + buttonSecondaryFill: Color(0xFFFEFFFD), + accent: Color(0xFFA7DC22), + secondary: Color(0xFF6E8F1B), + shadowColor: Color(0x33647e22), + a15p: Color(0x26a7dc22), + warningAccent: Color(0xFFFFA046), + warningText: Color(0xFF8F531B), + warning15p: Color(0x26FFA046), + warningCard: Color(0xFFFAEBDC), + errorAccent: Color(0xFFFF54A1), + errorText: Color(0xFF8F1B4F), + error15p: Color(0x26FF54A1), + errorCard: Color(0xFFFADCE9), + grade5: Color(0xFF22CCAD), + grade4: Color(0xFF92EA3B), + grade3: Color(0xFFF9CF00), + grade2: Color(0xFFFFA046), + grade1: Color(0xFFFF54A1), + ), + fonts: _defaultFonts); + +final FirkaStyle darkStyle = FirkaStyle( + colors: FirkaColors( + background: Color(0xFF0D1202), + backgroundAmoled: Colors.black, + background0p: Color(0x00fafff0), + success: Color(0xFF92EA3B), + shadowBlur: 0, + textPrimary: Color(0xFFEAF7CC), + textSecondary: Color(0xB3EAF7CC), + textTertiary: Color(0x80EAF7CC), + card: Color(0xFF141905), + cardTranslucent: Color(0x80141905), + buttonSecondaryFill: Color(0xFF20290B), + accent: Color(0xFFA7DC22), + secondary: Color(0xFFCBEE71), + shadowColor: Color(0x26CBEE71), + a15p: Color(0x26A7DC22), + warningAccent: Color(0xFFFFA046), + warningText: Color(0xFFF0B37A), + warning15p: Color(0x26FFA046), + warningCard: Color(0xFF201203), + errorAccent: Color(0xFFFF54A1), + errorText: Color(0xFFF59EC5), + error15p: Color(0x26FF54A1), + errorCard: Color(0xFF1E030F), + grade5: Color(0xFF22CCAD), + grade4: Color(0xFF92EA3B), + grade3: Color(0xFFF9CF00), + grade2: Color(0xFFFFA046), + grade1: Color(0xFFFF54A1), + ), + fonts: _defaultFonts); + +FirkaStyle appStyle = lightStyle; +FirkaStyle wearStyle = darkStyle; diff --git a/firka/lib/ui/phone/pages/error/error_page.dart b/firka/lib/ui/phone/pages/error/error_page.dart new file mode 100644 index 00000000..fba2a759 --- /dev/null +++ b/firka/lib/ui/phone/pages/error/error_page.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class ErrorPage extends StatelessWidget { + final String exception; + + const ErrorPage({super.key, required this.exception}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Error Occurred'), + backgroundColor: Colors.red, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error, + size: 80, + color: Colors.red, + ), + const SizedBox(height: 20), + Text( + 'An error occurred!', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.red, + ), + ), + const SizedBox(height: 10), + Text( + 'Details:', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + exception, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.redAccent, + ), + ), + const SizedBox(height: 30), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Go Back'), + ), + ], + ), + ), + ); + } +} diff --git a/firka/lib/ui/phone/pages/error/wear_error_page.dart b/firka/lib/ui/phone/pages/error/wear_error_page.dart new file mode 100644 index 00000000..5e923452 --- /dev/null +++ b/firka/lib/ui/phone/pages/error/wear_error_page.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +class WearErrorPage extends StatelessWidget { + final String exception; + + const WearErrorPage({super.key, required this.exception}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Error Occurred'), + backgroundColor: Colors.red, + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error, + size: 80, + color: Colors.red, + ), + const SizedBox(height: 20), + Text( + 'An error occurred!', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.red, + ), + ), + const SizedBox(height: 10), + Text( + 'Details:', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + Text( + exception, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.redAccent, + ), + ), + const SizedBox(height: 30), + ElevatedButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Go Back'), + ), + ], + ), + ), + ); + } +} diff --git a/firka/lib/ui/phone/pages/extras/extras.dart b/firka/lib/ui/phone/pages/extras/extras.dart new file mode 100644 index 00000000..3a2c2af3 --- /dev/null +++ b/firka/lib/ui/phone/pages/extras/extras.dart @@ -0,0 +1,61 @@ +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/main.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:flutter/material.dart'; + +import '../../screens/debug/debug_screen.dart'; + +void showExtrasBottomSheet(BuildContext context, AppInitialization data) { + showModalBottomSheet( + context: context, + elevation: 100, + isScrollControlled: true, + enableDrag: true, + backgroundColor: Colors.transparent, + barrierColor: appStyle.colors.a15p, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.3, + ), + builder: (BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: appStyle.colors.background, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + GestureDetector( + onTap: () => { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DebugScreen(data))) + }, + child: FirkaCard( + left: [Text('Debug screen')], + right: [], + ), + ) + ], + ), + ), + ), + ), + ], + ); + }, + ); +} diff --git a/firka/lib/ui/phone/pages/extras/main_error.dart b/firka/lib/ui/phone/pages/extras/main_error.dart new file mode 100644 index 00000000..1b85a08e --- /dev/null +++ b/firka/lib/ui/phone/pages/extras/main_error.dart @@ -0,0 +1,47 @@ +import 'package:firka/ui/model/style.dart'; +import 'package:flutter/material.dart'; + +void showErrorBottomSheet(BuildContext context, String err) { + showModalBottomSheet( + context: context, + elevation: 100, + isScrollControlled: true, + enableDrag: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.transparent, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.3, + ), + builder: (BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: appStyle.colors.card, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + err, + style: appStyle.fonts.B_14R, + ), + ), + ), + ), + ), + ], + ); + }, + ); +} diff --git a/firka/lib/ui/phone/pages/home/home_grades.dart b/firka/lib/ui/phone/pages/home/home_grades.dart new file mode 100644 index 00000000..093ea898 --- /dev/null +++ b/firka/lib/ui/phone/pages/home/home_grades.dart @@ -0,0 +1,168 @@ +import 'package:firka/helpers/api/model/generic.dart'; +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/helpers/ui/grade_helpers.dart'; +import 'package:firka/helpers/ui/stateless_async_widget.dart'; +import 'package:firka/ui/phone/screens/home/home_screen.dart'; +import 'package:firka/ui/widget/grade_small_card.dart'; +import 'package:flutter/material.dart'; + +import '../../../../helpers/api/model/subject.dart'; +import '../../../../helpers/debug_helper.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../main.dart'; +import '../../../model/style.dart'; + +class HomeGradesScreen extends StatelessAsyncWidget { + final AppInitialization data; + final void Function(ActiveHomePage) cb; + + const HomeGradesScreen(this.data, this.cb, {super.key}); + + @override + Future buildAsync(BuildContext context) async { + var now = timeNow(); + var start = now.subtract(Duration(days: now.weekday - 1)); + var end = start.add(Duration(days: 6)); + + var grades = await data.client.getGrades(); + var subjectAvg = 0.00; + var week = await data.client.getTimeTable(start, end); + final List subjects = List.empty(growable: true); + final List gradeCards = []; + + for (var grade in grades.response!) { + if (subjects.where((s) => s.uid == grade.subject.uid).isEmpty) { + subjects.add(grade.subject); + } + } + + subjects.sort((s1, s2) => s1.name.compareTo(s2.name)); + + for (var subject in subjects) { + for (var grade in grades.response!) { + if (grade.subject.uid != subject.uid) continue; + + if (grade.valueType.name == "Szazalekos") { + grade.valueType = NameUidDesc( + uid: "1,Osztalyzat", name: "Osztalyzat", description: ""); + if (grade.numericValue != null) { + grade.numericValue = percentageToGrade(grade.numericValue!); + } + } + } + var avg = grades.response!.getAverageBySubject(subject); + + if (avg.isNaN) { + gradeCards.add(GradeSmallCard(grades.response!, subject)); + } else { + gradeCards.add(GestureDetector( + child: GradeSmallCard(grades.response!, subject), + onTap: () { + cb(ActiveHomePage(HomePages.grades, subPageUid: subject.uid)); + }, + )); + } + + subjectAvg += roundGrade(avg); + } + + subjectAvg /= subjects.length; + + var subjectAvgColor = getGradeColor(subjectAvg); + + return Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + AppLocalizations.of(context)!.subjects, + style: appStyle.fonts.H_H2 + .apply(color: appStyle.colors.textSecondary), + ) + ], + ), + SizedBox(height: 16), // TODO: Add graphs here + // ...gradeCards, + SizedBox( + height: MediaQuery.of(context).size.height - + MediaQuery.of(context).padding.top - + 230, + child: ListView( + children: [ + Text( + AppLocalizations.of(context)!.your_subjects, + style: appStyle.fonts.H_14px + .apply(color: appStyle.colors.textSecondary), + ), + SizedBox(height: 16), + ...gradeCards, + SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.data, + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textSecondary), + ), + SizedBox(height: 16), + FirkaCard( + left: [ + Text( + AppLocalizations.of(context)!.subject_avg, + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary), + ), + ], + right: [ + Card( + shadowColor: Colors.transparent, + color: subjectAvgColor.withAlpha(38), + child: Padding( + padding: EdgeInsets.only( + left: 8, right: 8, top: 4, bottom: 4), + child: Text( + subjectAvg.toStringAsFixed(2), + style: appStyle.fonts.B_16SB + .apply(color: subjectAvgColor), + ), + ), + ), + ], + ), + FirkaCard(left: [ + Text( + AppLocalizations.of(context)!.class_avg, + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary), + ), + ]), + FirkaCard( + left: [ + Text( + AppLocalizations.of(context)!.class_n, + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary), + ), + ], + right: [ + Text( + week.response!.length.toString(), + style: appStyle.fonts.B_14SB + .apply(color: appStyle.colors.textPrimary), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/firka/lib/ui/phone/pages/home/home_grades_subject.dart b/firka/lib/ui/phone/pages/home/home_grades_subject.dart new file mode 100644 index 00000000..89800fac --- /dev/null +++ b/firka/lib/ui/phone/pages/home/home_grades_subject.dart @@ -0,0 +1,130 @@ +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/helpers/ui/grade.dart'; +import 'package:firka/helpers/ui/stateless_async_widget.dart'; +import 'package:flutter/material.dart'; + +import '../../../../l10n/app_localizations.dart'; +import '../../../../main.dart'; +import '../../../model/style.dart'; + +class HomeGradesSubjectScreen extends StatelessAsyncWidget { + final AppInitialization data; + final String subPageUid; + + const HomeGradesSubjectScreen(this.data, this.subPageUid, {super.key}); + + @override + Future buildAsync(BuildContext context) async { + var grades = (await data.client.getGrades()) + .response! + .where((grade) => grade.subject.uid == subPageUid) + .where((grade) => grade.type.name != "felevi_jegy_ertekeles"); + var aGrade = grades.first; + var groups = grades.groupList((grade) => grade.recordDate); + + var gradeWidgets = List.empty(growable: true); + + for (var group in groups.entries) { + gradeWidgets.add(SizedBox( + height: 8, + )); + gradeWidgets.add(Text( + group.key.format(context, FormatMode.grades), + style: appStyle.fonts.H_14px, + )); + gradeWidgets.add(SizedBox( + height: 8, + )); + for (var grade in group.value) { + gradeWidgets.add(FirkaCard( + left: [ + Row( + children: [ + GradeWidget(grade), + SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + grade.topic ?? grade.type.description!, + style: appStyle.fonts.B_14SB, + ), + Text( + grade.mode?.description ?? "", + style: appStyle.fonts.B_14R, + ) + ], + ) + ], + ) + ], + )); + } + } + + return Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + AppLocalizations.of(context)!.subjects, + style: appStyle + .fonts.H_16px // TODO: Replace this with the proper font + .apply(color: appStyle.colors.textPrimary), + ) + ], + ), + SizedBox(height: 16), + SizedBox( + height: MediaQuery.of(context).size.height - + MediaQuery.of(context).padding.top - + 230, + child: ListView( + children: [ + FirkaCard( + left: [ + Padding( + padding: EdgeInsets.only(left: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + aGrade.subject.name, + style: appStyle.fonts.H_H2, + ), + Text( + aGrade.teacher, // For some reason the teacher's + // name isn't stored in the subject, so we need + // to get *a* grade, and then get the teacher's + // name from there :3 + style: appStyle.fonts.B_14R, + ) + ], + ), + ) + ], + ), + Padding( + padding: EdgeInsets.only(left: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: gradeWidgets, + ), + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/firka/lib/ui/phone/pages/home/home_main.dart b/firka/lib/ui/phone/pages/home/home_main.dart new file mode 100644 index 00000000..d1a35441 --- /dev/null +++ b/firka/lib/ui/phone/pages/home/home_main.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/ui/phone/widgets/home_main_starting_soon.dart'; +import 'package:firka/ui/phone/widgets/lesson_small.dart'; +import 'package:firka/ui/widget/delayed_spinner.dart'; +import 'package:flutter/material.dart'; + +import '../../../../helpers/api/model/student.dart'; +import '../../../../helpers/api/model/timetable.dart'; +import '../../../../helpers/debug_helper.dart'; +import '../../../../main.dart'; +import '../../widgets/home_main_welcome.dart'; +import '../../widgets/lesson_big.dart'; + +class HomeMainScreen extends StatefulWidget { + final AppInitialization data; + + const HomeMainScreen(this.data, {super.key}); + + @override + State createState() => _HomeMainScreen(data); +} + +class _HomeMainScreen extends State { + final AppInitialization data; + + _HomeMainScreen(this.data); + + DateTime now = timeNow(); + List? lessons; + Student? student; + Timer? timer; + + @override + void initState() { + super.initState(); + + now = timeNow(); + var midnight = now.getMidnight(); + (() async { + var resp = await data.client.getTimeTable( + midnight, midnight.add(Duration(hours: 23, minutes: 59))); + + setState(() { + lessons = resp.response!; + }); + })(); + (() async { + var resp = await data.client.getStudent(); + + setState(() { + student = resp.response!; + }); + })(); + + timer = Timer.periodic(Duration(seconds: 1), (timer) async { + setState(() { + now = timeNow(); + }); + }); + } + + @override + void dispose() { + super.dispose(); + + timer?.cancel(); + } + + @override + Widget build(BuildContext context) { + Widget welcomeWidget = SizedBox(); + Widget nextClass = SizedBox(); + + if (lessons != null && lessons!.isNotEmpty) { + if (now.isBefore(lessons!.first.start)) { + welcomeWidget = StartingSoonWidget(now, lessons!); + } else { + var currentLesson = lessons!.firstWhereOrNull( + (lesson) => now.isAfter(lesson.start) && now.isBefore(lesson.end)); + // "fun" fact if your clock was exactly when the class ends then isBefore + // and isAfter would fail, so to work around that we just add 1ms to the + // current time + var prevLesson = lessons!.getPrevLesson(now); + var nextLesson = lessons!.getNextLesson(now); + int? lessonIndex; + + if (currentLesson != null) { + lessonIndex = lessons!.getLessonNo(currentLesson); + } + + welcomeWidget = LessonBigWidget( + now, lessonIndex, currentLesson, prevLesson, nextLesson); + } + } + if (lessons != null && lessons!.isNotEmpty) { + var nextLesson = lessons!.getNextLesson(now); + if (nextLesson != null) nextClass = LessonSmallWidget(nextLesson); + } + + if (student != null && lessons != null) { + return Flexible( + child: Padding( + padding: const EdgeInsets.only( + left: 20.0, + top: 24.0, + right: 20.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + WelcomeWidget(now, student!, lessons!), + SizedBox(height: 48), + welcomeWidget, + nextClass + ], + ), + ), + ); + } else { + return DelayedSpinnerWidget(); + } + } +} diff --git a/firka/lib/ui/phone/pages/home/home_timetable.dart b/firka/lib/ui/phone/pages/home/home_timetable.dart new file mode 100644 index 00000000..24b5354f --- /dev/null +++ b/firka/lib/ui/phone/pages/home/home_timetable.dart @@ -0,0 +1,207 @@ +import 'package:firka/helpers/api/model/timetable.dart'; +import 'package:firka/helpers/debug_helper.dart'; +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/l10n/app_localizations.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:firka/ui/phone/widgets/bottom_tt_icon.dart'; +import 'package:firka/ui/phone/widgets/lesson.dart'; +import 'package:firka/ui/widget/delayed_spinner.dart'; +import 'package:firka/ui/widget/firka_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:majesticons_flutter/majesticons_flutter.dart'; + +import '../../../../main.dart'; + +class HomeTimetableScreen extends StatefulWidget { + final AppInitialization data; + const HomeTimetableScreen(this.data, {super.key}); + + @override + State createState() => _HomeTimetableScreen(data); +} + +class _HomeTimetableScreen extends State { + final AppInitialization data; + List? lessons; + List? dates; + DateTime? active; + _HomeTimetableScreen(this.data); + + @override + void initState() { + super.initState(); + + var monday = timeNow().getMonday().getMidnight(); + var sunday = monday.add(Duration(days: 6)); + + (() async { + var lessonsResp = await data.client.getTimeTable(monday, sunday); + List dates = List.empty(growable: true); + + if (lessonsResp.response != null) { + lessons = lessonsResp.response; + + for (var i = 0; i < 7; i++) { + var t = monday.add(Duration(days: i)); + + var hasLessons = i < 5 || + lessons!.firstWhereOrNull((lesson) { + return lesson.start.getMidnight().millisecondsSinceEpoch == + t.getMidnight().millisecondsSinceEpoch; + }) != + null; + + if (hasLessons) { + dates.add(t); + } + } + } + + setState(() { + this.dates = dates; + if (timeNow().isAfter(dates.last)) { + active = dates.last; + } else { + active = timeNow().getMidnight(); + } + }); + })(); + } + + @override + Widget build(BuildContext context) { + if (lessons != null && dates != null && active != null) { + List ttWidgets = List.empty(growable: true); + var lessonsToday = lessons! + .where((lesson) => + lesson.start.isAfter(active!) && + lesson.end.isBefore(active!.add(Duration(hours: 24)))) + .toList(); + + for (final date in dates!) { + ttWidgets.add(BottomTimeTableNavIconWidget(() { + setState(() { + active = date; + }); + }, date.millisecondsSinceEpoch == active!.millisecondsSinceEpoch, + date)); + } + + Widget noLessonsWidget = SizedBox(); + List ttBody = List.empty(growable: true); + + if (lessonsToday.isEmpty) { + noLessonsWidget = Positioned.fill( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SvgPicture.asset("assets/images/logos/dave.svg", + width: 48, height: 48), + SizedBox(height: 12), + Text(AppLocalizations.of(context)!.tt_no_classes_l1), + Text(AppLocalizations.of(context)!.tt_no_classes_l2) + ]), + ); + } else { + for (var i = 0; i < lessonsToday.length; i++) { + var lesson = lessonsToday[i]; + Lesson? nextLesson = + lessonsToday.length > i + 1 ? lessonsToday[i + 1] : null; + ttBody.add(LessonWidget( + lessonsToday.getLessonNo(lesson), lesson, nextLesson)); + } + } + + return Expanded( + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.only( + top: 16.0, + left: 32.0, + right: 16.0, + ), + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + AppLocalizations.of(context)!.timetable, + style: appStyle.fonts.H_H2 + .apply(color: appStyle.colors.textPrimary), + ), + Row( + children: [ + Card( + color: appStyle.colors.buttonSecondaryFill, + child: Padding( + padding: const EdgeInsets.all(8), + child: FirkaIconWidget( + FirkaIconType.Majesticons, + Majesticon.tableSolid, + size: 26.0, + color: appStyle.colors.accent, + ), + ), + ), + Card( + color: appStyle.colors.buttonSecondaryFill, + child: Padding( + padding: const EdgeInsets.all(4), + child: FirkaIconWidget( + FirkaIconType.Majesticons, + Majesticon.plusLine, + size: 32.0, + color: appStyle.colors.accent, + ), + ), + ), + Card( + color: appStyle.colors.buttonSecondaryFill, + child: Padding( + padding: const EdgeInsets.all(8), + child: FirkaIconWidget( + FirkaIconType.Majesticons, + Majesticon.settingsCogSolid, + size: 26.0, + color: appStyle.colors.accent, + ), + ), + ) + ], + ), + ], + ), + ]), + ), + noLessonsWidget, + SizedBox( + height: MediaQuery.of(context).size.height - + MediaQuery.of(context).padding.top - + 230, + child: Padding( + padding: + EdgeInsets.only(top: 70, left: 35, right: 35, bottom: 15), + child: ListView( + children: ttBody, + ), + ), + ), + Padding( + padding: + EdgeInsets.only(top: MediaQuery.of(context).size.height - 250), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: ttWidgets, + ), + ), + ], + )); + } else { + return DelayedSpinnerWidget(); + } + } +} diff --git a/firka/lib/ui/phone/screens/debug/debug_screen.dart b/firka/lib/ui/phone/screens/debug/debug_screen.dart new file mode 100644 index 00000000..2a1e823b --- /dev/null +++ b/firka/lib/ui/phone/screens/debug/debug_screen.dart @@ -0,0 +1,241 @@ +// ignore_for_file: avoid_print + +import 'dart:typed_data'; + +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/helpers/icon_helper.dart'; +import 'package:firka/helpers/profile_picture.dart'; +import 'package:firka/main.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../../helpers/debug_helper.dart'; +import '../../../widget/firka_icon.dart'; + +class DebugScreen extends StatefulWidget { + final AppInitialization data; + + const DebugScreen(this.data, {super.key}); + + @override + State createState() => _DebugScreen(data); +} + +class _DebugScreen extends State { + final AppInitialization data; + + _DebugScreen(this.data); + + late ImagePicker _picker; + Uint8List? profilePictureData; + + bool useCache = true; + + @override + void initState() { + super.initState(); + + _picker = ImagePicker(); + profilePictureData = data.profilePicture; + } + + @override + Widget build(BuildContext context) { + Widget profilePicture = SizedBox(height: 0); + if (profilePictureData != null) { + profilePicture = Image.memory(profilePictureData!); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Debug'), + centerTitle: true, + ), + body: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Debug Screen', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Text('use cache'), + Switch( + value: useCache, + onChanged: (bool value) { + setState(() { + useCache = value; + }); + }, + ) + ], + ), + const SizedBox(height: 5), + Row( + children: [ + Text('tick debug timer'), + Switch( + value: debugTimeAdvance, + onChanged: (bool value) { + setState(() { + debugTimeAdvance = value; + }); + }, + ) + ], + ), + const SizedBox(height: 20), + profilePicture, + ElevatedButton( + onPressed: () async { + await pickProfilePicture(data, _picker); + + setState(() { + if (data.profilePicture != null) { + profilePictureData = data.profilePicture; + } + }); + }, + child: const Text('Pick pfp'), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () async { + var d = await showDatePicker( + context: context, + firstDate: DateTime.now().subtract(Duration(days: 365)), + lastDate: DateTime.now().add(Duration(days: 365))); + + var t = await showTimePicker( + context: context, initialTime: TimeOfDay.now()); + + if (d != null && t != null) { + debugFakeTime = d + .getMidnight() + .add(Duration(hours: t.hour, minutes: t.minute)); + + debugSetAt = DateTime.now(); + } + }, + child: const Text('Set fake time'), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () { + throw 0 / 0; + }, + child: const Text('Throw Exception'), + ), + ElevatedButton( + onPressed: () async { + print( + "getStudent(): ${await data.client.getStudent(forceCache: useCache)}"); + }, + child: const Text('getStudent()'), + ), + ElevatedButton( + onPressed: () async { + print( + "getNoticeBoard(): ${await data.client.getNoticeBoard(forceCache: useCache)}"); + }, + child: const Text('getNoticeBoard()'), + ), + ElevatedButton( + onPressed: () async { + print( + "getGrades(): ${await data.client.getGrades(forceCache: useCache)}"); + }, + child: const Text('getGrades()'), + ), + ElevatedButton( + onPressed: () async { + var now = timeNow(); + + var start = now.subtract(Duration(days: 14)); + var end = now.add(Duration(days: 7)); + + print( + "getLessons(): ${await data.client.getTimeTable(start, end, forceCache: useCache)}"); + }, + child: const Text('getLessons()'), + ), + ElevatedButton( + onPressed: () async { + var now = timeNow(); + + var start = now.subtract(Duration(days: 7)); + var end = now.add(Duration(days: 14)); + + print( + "getHomework(): ${await data.client.getHomework(start, end, forceCache: useCache)}"); + }, + child: const Text('getHomework()'), + ), + ElevatedButton( + onPressed: () async { + print( + "getTests(): ${await data.client.getTests(forceCache: useCache)}"); + }, + child: const Text('getTests()'), + ), + ElevatedButton( + onPressed: () async { + print( + "getOmissions(): ${await data.client.getOmissions(forceCache: useCache)}"); + }, + child: const Text('getOmissions()'), + ), + ElevatedButton( + onPressed: () { + setState(() {}); + }, + child: const Text('re-render'), + ), + SizedBox( + height: 600, + child: GridView.count( + crossAxisCount: 2, + children: ClassIcon.values.map((e) { + return Column( + children: [ + Center( + child: Text( + e.name, + style: TextTheme.of(context).headlineSmall, + ), + ), + Center( + child: FirkaIconWidget( + FirkaIconType.Majesticons, getIconData(e), + color: Colors.black), + ) + ], + ); + }).toList(), + /* + children: List.generate(100, (index) { + return Center( + child: Text( + 'Item $index', + style: TextTheme.of(context).headlineSmall, + ), + ); + }), + */ + ), + ), + SizedBox(height: 32), + ], + ), + ), + ), + ); + } +} diff --git a/firka/lib/ui/phone/screens/home/home_screen.dart b/firka/lib/ui/phone/screens/home/home_screen.dart new file mode 100644 index 00000000..c3060fd9 --- /dev/null +++ b/firka/lib/ui/phone/screens/home/home_screen.dart @@ -0,0 +1,375 @@ +import 'dart:async'; + +import 'package:firka/helpers/api/client/kreta_client.dart'; +import 'package:firka/main.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:firka/ui/phone/pages/home/home_grades.dart'; +import 'package:firka/ui/phone/pages/home/home_main.dart'; +import 'package:firka/ui/phone/widgets/bottom_nav_icon.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:majesticons_flutter/majesticons_flutter.dart'; + + +import '../../../../helpers/debug_helper.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../widget/firka_icon.dart'; +import '../../pages/extras/extras.dart'; +import '../../pages/extras/main_error.dart'; +import '../../pages/home/home_grades_subject.dart'; +import '../../pages/home/home_timetable.dart'; +import '../debug/debug_screen.dart'; + +class HomeScreen extends StatefulWidget { + final AppInitialization data; + + const HomeScreen(this.data, {super.key}); + + @override + State createState() => _HomeScreenState(data); +} + +enum HomePages { home, grades, timetable } + +enum ActiveToastType { fetching, error, none } + +class ActiveHomePage { + final HomePages page; + final String? subPageUid; + + ActiveHomePage(this.page, {this.subPageUid}); + + @override + int get hashCode => (page.hashCode ^ subPageUid.hashCode); + + @override + bool operator ==(Object other) { + return (other is ActiveHomePage) && hashCode == other.hashCode; + } +} + +bool _fetching = true; +bool _prefetched = false; +bool canPop = true; + +class _HomeScreenState extends State { + final AppInitialization data; + + _HomeScreenState(this.data); + + ActiveHomePage page = ActiveHomePage(HomePages.home); + List previousPages = List.empty(growable: true); + + Widget? toast; + + ActiveToastType activeToast = ActiveToastType.none; + + void setPageCB(ActiveHomePage newPage) { + setState(() { + previousPages.add(page); + canPop = false; + page = newPage; + }); + } + + void prefetch() async { + if (_prefetched) return; + + try { + _prefetched = true; + + ApiResponse res = await data.client.getGrades(forceCache: false); + + if (res.err != null) throw res.err!; + + var now = timeNow(); + var start = now.subtract(Duration(days: now.weekday - 1)); + var end = start.add(Duration(days: 6)); + + res = await data.client.getTimeTable(start, end, forceCache: false); + + if (res.err != null) throw res.err!; + } catch (e) { + activeToast = ActiveToastType.error; + + var dismissDelay = 120; + if (kDebugMode) { + dismissDelay = 2; + } + Timer(Duration(seconds: dismissDelay), () { + setState(() { + activeToast = ActiveToastType.none; + toast = null; + }); + }); + + setState(() { + // TODO: Make this and the error toast more rounded + toast = Positioned( + top: MediaQuery.of(context).size.height / 1.6, + left: 0.0, + right: 0.0, + bottom: 0, + child: Center( + child: Card( + color: appStyle.colors.errorCard, + shadowColor: Colors.transparent, + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + mainAxisSize: MainAxisSize.min, + // Use min to prevent filling the width + children: [ + Text( + AppLocalizations.of(context)!.api_error, + style: appStyle.fonts.B_14SB + .copyWith(color: appStyle.colors.errorText), + ), + SizedBox(width: 8), + GestureDetector( + child: FirkaIconWidget(FirkaIconType.Majesticons, + Majesticon.questionCircleSolid, + color: appStyle.colors.errorAccent, size: 24), + onTap: () { + showErrorBottomSheet(context, e.toString()); + }, + ), + ], + ), + ), + ), + ), + ); + }); + } finally { + setState(() { + _fetching = false; + + if (activeToast == ActiveToastType.fetching) toast = null; + }); + } + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateSystemUI(); + }); + + prefetch(); + } + + void _updateSystemUI() { + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + statusBarColor: Colors.transparent, + systemNavigationBarColor: appStyle.colors.background, + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarDividerColor: Colors.transparent, + )); + } + + @override + Widget build(BuildContext context) { + _updateSystemUI(); // Update system UI on every build, to compensate for the android system being dumb + + if (_fetching) { + setState(() { + activeToast = ActiveToastType.fetching; + toast = Positioned( + top: MediaQuery.of(context).size.height / 1.6, + left: 0.0, + right: 0.0, + bottom: 0, + child: Center( + child: Card( + color: appStyle.colors.card, + shadowColor: Colors.transparent, + child: Padding( + padding: EdgeInsets.all(8), + child: Row( + mainAxisSize: MainAxisSize.min, + // Use min to prevent filling the width + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: appStyle.colors.accent, + ), + ), + SizedBox(width: 16), + Text( + AppLocalizations.of(context)!.refreshing, + style: appStyle.fonts.B_14SB + .copyWith(color: appStyle.colors.textPrimary), + ) + ], + ), + ), + ), + ), + ); + }); + } + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + return PopScope( + canPop: canPop, + child: Scaffold( + backgroundColor: appStyle.colors.background, + body: SafeArea( + child: SizedBox( + height: MediaQuery.of(context).size.height, + child: Stack( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [HomeSubPage(page, setPageCB, data)], + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + appStyle.colors.background, + appStyle.colors.background.withValues(alpha: 0.0), + ], + stops: const [0.0, 1.0], + ), + ), + width: MediaQuery.of(context).size.width, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 55, vertical: 15), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Home Button + BottomNavIconWidget(() { + if (page.page != HomePages.home) { + HapticFeedback.lightImpact(); + + setState(() { + previousPages.add(page); + canPop = false; + page = ActiveHomePage(HomePages.home); + }); + } + }, + page.page == HomePages.home, + page.page == HomePages.home + ? Majesticon.homeSolid + : Majesticon.homeLine, + AppLocalizations.of(context)!.home, + page.page == HomePages.home + ? appStyle.colors.accent + : appStyle.colors.secondary, + appStyle.colors.textPrimary), + // Grades Button + BottomNavIconWidget(() { + if (page.page != HomePages.grades) { + HapticFeedback.lightImpact(); + + setState(() { + previousPages.add(page); + canPop = false; + page = ActiveHomePage(HomePages.grades); + }); + } + }, + page.page == HomePages.grades, + page.page == HomePages.grades + ? Majesticon.bookmarkSolid + : Majesticon.bookmarkLine, + AppLocalizations.of(context)!.grades, + page.page == HomePages.grades + ? appStyle.colors.accent + : appStyle.colors.secondary, + appStyle.colors.textPrimary), + // Timetable Button + BottomNavIconWidget(() { + if (page.page != HomePages.timetable) { + HapticFeedback.lightImpact(); + + setState(() { + previousPages.add(page); + canPop = false; + page = ActiveHomePage(HomePages.timetable); + }); + } + }, + page.page == HomePages.timetable, + page.page == HomePages.timetable + ? Majesticon.calendarSolid + : Majesticon.calendarLine, + AppLocalizations.of(context)!.timetable, + page.page == HomePages.timetable + ? appStyle.colors.accent + : appStyle.colors.secondary, + appStyle.colors.textPrimary), + // More Button + BottomNavIconWidget(() { + HapticFeedback.lightImpact(); + showExtrasBottomSheet(context, data); + }, + false, + Majesticon.globeEarthLine, + AppLocalizations.of(context)!.other, + appStyle.colors.secondary, + appStyle.colors.textPrimary), + ], + ), + ), + ), + ], + ), + toast ?? SizedBox(), + ], + ), + ), + ), + ), + onPopInvokedWithResult: (_, __) => { + if (previousPages.isNotEmpty && page != previousPages.last) + { + setState(() { + page = previousPages.removeLast(); + canPop = previousPages.isEmpty; + }) + } + }, + ); + } +} + +class HomeSubPage extends StatelessWidget { + final ActiveHomePage page; + final void Function(ActiveHomePage) cb; + final AppInitialization data; + + const HomeSubPage(this.page, this.cb, this.data, {super.key}); + + @override + Widget build(BuildContext context) { + switch (page.page) { + case HomePages.home: + return HomeMainScreen(data); + case HomePages.grades: + if (page.subPageUid != null) { + return HomeGradesSubjectScreen(data, page.subPageUid!); + } else { + return HomeGradesScreen(data, cb); + } + case HomePages.timetable: + return HomeTimetableScreen(data); + } + } +} diff --git a/firka/lib/ui/phone/screens/login/login_screen.dart b/firka/lib/ui/phone/screens/login/login_screen.dart new file mode 100644 index 00000000..6b1749cb --- /dev/null +++ b/firka/lib/ui/phone/screens/login/login_screen.dart @@ -0,0 +1,492 @@ +import 'dart:async'; + +import 'package:carousel_slider/carousel_slider.dart'; +import 'package:firka/helpers/api/client/kreta_client.dart'; +import 'package:firka/helpers/api/consts.dart'; +import 'package:firka/helpers/db/models/token_model.dart'; +import 'package:firka/main.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../../../../helpers/api/token_grant.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../model/style.dart'; +import '../home/home_screen.dart'; + +class LoginScreen extends StatefulWidget { + final AppInitialization data; + + const LoginScreen(this.data, {super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + late WebViewController _webViewController; + Timer? _timer; + + @override + void initState() { + super.initState(); + + _webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..loadRequest(Uri.parse(KretaEndpoints.kretaLoginUrl)) + ..setNavigationDelegate(NavigationDelegate( + onNavigationRequest: (NavigationRequest request) async { + var uri = Uri.parse(request.url); + + if (uri.path == "/ellenorzo-student/prod/oauthredirect") { + if (kDebugMode) { + print("query params: ${uri.queryParameters}"); + } + + var code = uri.queryParameters["code"]!; + + try { + var isar = widget.data.isar; + var resp = await getAccessToken(code); + + if (kDebugMode) { + print("getAccessToken(): $resp"); + } + + var tokenModel = TokenModel.fromResp(resp); + + await isar.writeTxn(() async { + await isar.tokenModels.put(tokenModel); + }); + + widget.data.client = KretaClient(tokenModel, isar); + widget.data.tokenCount = await isar.tokenModels.count(); + + if (!mounted) return NavigationDecision.prevent; + + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (context) => HomeScreen(widget.data)), + (route) => false, // Remove all previous routes + ); + } catch (ex) { + if (kDebugMode) { + print("oauthredirect failed: $ex"); + } + // TODO: display an error popup + } + + return NavigationDecision.prevent; + } + + return NavigationDecision.navigate; + })); + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + statusBarColor: Colors.transparent, + systemNavigationBarColor: Color(0xFFFAFFF0), + )); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void startTimer() { + _timer = Timer(const Duration(seconds: 3), () { + showModalBottomSheet( + context: context, + elevation: 100, + isScrollControlled: true, + enableDrag: true, + backgroundColor: Colors.transparent, + barrierColor: Colors.transparent, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.5, + ), + builder: (BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: appStyle.colors.card, + borderRadius: + BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: TextFormField( + decoration: const InputDecoration( + border: UnderlineInputBorder(), + labelText: 'Kréta api hostname(:port)', + ), + onChanged: (v) { + initData.settings.customHost = v; + }, + ), + ), + SizedBox(height: 10), + Container( + width: double.infinity, + height: 48, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: ShapeDecoration( + color: const Color(0xFFA7DB21), // Accent-Accent + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + shadows: const [ + BoxShadow( + color: Color(0x33647E22), + blurRadius: 2, + offset: Offset(0, 1), + spreadRadius: 0, + ) + ], + ), + child: GestureDetector( + child: Center( + child: Text( + 'Set hostname', + textAlign: TextAlign.center, + style: appStyle.fonts.H_18px.copyWith( + color: appStyle.colors.textPrimary), + ), + ), + onTap: () { + if (initData.settings.customHost != null && + initData.settings.customHost != "") { + var host = initData.settings.customHost!; + KretaEndpoints.kretaBase = "https://$host"; + KretaEndpoints.kretaIdp = + KretaEndpoints.kretaBase; + 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"; + + initData.settings.useCustomHost = true; + } else { + KretaEndpoints.kretaBase = "e-kreta.hu"; + KretaEndpoints.kretaIdp = + "https://idp.e-kreta.hu"; + 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"; + + initData.settings.useCustomHost = false; + } + + // TODO: Fix this + initData.saveSettings(); + setState(() { + _webViewController.loadRequest( + Uri.parse(KretaEndpoints.kretaLoginUrl)); + }); + + Navigator.pop(context); + }, + )), + // TODO: fix this insane shitcode + SizedBox( + height: MediaQuery.of(context).viewInsets.bottom * + 1000), + SizedBox(height: 75), + ], + ), + ), + ), + ), + ], + ); + }, + ); + }); + } + + @override + Widget build(BuildContext context) { + final paddingWidthHorizontal = MediaQuery.of(context).size.width - + MediaQuery.of(context).size.width * 0.95; + List> slides = [ + { + 'title': AppLocalizations.of(context)!.title1, + 'subtitle': AppLocalizations.of(context)!.subtitle1, + 'picture': 'assets/images/carousel/slide1.png', + }, + { + 'title': AppLocalizations.of(context)!.title2, + 'subtitle': AppLocalizations.of(context)!.subtitle2, + 'picture': 'assets/images/carousel/slide2.png', + }, + { + 'title': AppLocalizations.of(context)!.title3, + 'subtitle': AppLocalizations.of(context)!.subtitle3, + 'picture': 'assets/images/carousel/slide3.png', + }, + { + 'title': AppLocalizations.of(context)!.title4, + 'subtitle': AppLocalizations.of(context)!.subtitle4, + 'picture': 'assets/images/carousel/slide4.png', + } + ]; + + return MaterialApp( + home: Scaffold( + backgroundColor: const Color(0xFFFAFFEF), + body: SafeArea( + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Padding( + padding: EdgeInsets.only(left: paddingWidthHorizontal), + child: Row( + children: [ + Container( + width: 30, + height: 30, + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + image: const DecorationImage( + image: AssetImage( + 'assets/images/logos/colored_logo.png'), + fit: BoxFit.cover, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + const SizedBox(width: 8), + Text( + 'Firka Napló', + style: appStyle.fonts.H_18px + .copyWith(color: appStyle.colors.textPrimary), + ), + ], + ), + ), + const SizedBox(height: 16), + Expanded( + child: CarouselSlider.builder( + itemCount: slides.length, + itemBuilder: (context, index, realIndex) => Padding( + padding: EdgeInsets.symmetric( + horizontal: paddingWidthHorizontal), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + slides[index]['title']!, + style: appStyle.fonts.H_18px + .copyWith(color: appStyle.colors.textPrimary), + softWrap: true, + overflow: TextOverflow.visible, + ), + const SizedBox(height: 8), + Text( + slides[index]['subtitle']!, + style: appStyle.fonts.B_14R + .copyWith(color: appStyle.colors.textPrimary), + softWrap: true, + overflow: TextOverflow.visible, + ), + const SizedBox(height: 38), + SizedBox( + width: MediaQuery.of(context).size.width, + child: Image( + image: AssetImage(slides[index]['picture']!), + fit: BoxFit.cover, + width: double.infinity, + alignment: Alignment.center, + ), + ), + ], + ), + ), + options: CarouselOptions( + height: double.infinity, + autoPlay: true, + autoPlayInterval: const Duration(milliseconds: 4000), + viewportFraction: 1.0, + enableInfiniteScroll: true, + ), + ), + ), + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + width: double.infinity, + height: 200, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x00FAFFF0), + Color(0xFFFAFFF0) + ], // customize colors + stops: [0.0, 0.5], // percentages (0% → 50% → 100%) + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ) + ], + ), + Positioned( + bottom: 10, + left: 0, + right: 0, + child: Column( + children: [ + Center( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: paddingWidthHorizontal), + child: InkWell( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context) + .viewInsets + .bottom), + child: FractionallySizedBox( + heightFactor: 0.90, + child: Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment.center, + mainAxisAlignment: + MainAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric( + vertical: 16), + child: Container( + decoration: const BoxDecoration( + color: Color(0xFFB9C8E5), + borderRadius: + BorderRadius.all( + Radius.circular(2)), + ), + width: 40, + height: 4, + ), + ), + ], + ), + Container( + height: MediaQuery.of(context) + .size + .height * + 0.8, + // Adjust height for content + margin: const EdgeInsets.symmetric( + horizontal: 16), + // Add ClipRRect for circular edges + child: ClipRRect( + borderRadius: + BorderRadius.circular(20), + child: WebViewWidget( + controller: _webViewController, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + height: 48, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: ShapeDecoration( + color: const Color(0xFFA7DB21), // Accent-Accent + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + shadows: const [ + BoxShadow( + color: Color(0x33647E22), + blurRadius: 2, + offset: Offset(0, 1), + spreadRadius: 0, + ) + ], + ), + child: Center( + child: Text( + AppLocalizations.of(context)!.loginBtn, + textAlign: TextAlign.center, + style: appStyle.fonts.H_16px.copyWith( + color: appStyle.colors.textPrimary, + fontVariations: [FontVariation("wght", 800)]), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + GestureDetector( + child: Text( + AppLocalizations.of(context)!.privacyLabel, + textAlign: TextAlign.center, + style: appStyle.fonts.H_12px + .copyWith(color: appStyle.colors.textTertiary), + ), + onTapDown: (_) { + startTimer(); + }, + onTapUp: (_) { + _timer?.cancel(); + }, + ) + ], + ), + ), + ], + )), + ), + ); + } +} diff --git a/firka/lib/ui/phone/screens/wear_login/wear_login_screen.dart b/firka/lib/ui/phone/screens/wear_login/wear_login_screen.dart new file mode 100644 index 00000000..32721502 --- /dev/null +++ b/firka/lib/ui/phone/screens/wear_login/wear_login_screen.dart @@ -0,0 +1,336 @@ +import 'package:firka/helpers/api/consts.dart'; +import 'package:firka/helpers/db/models/token_model.dart'; +import 'package:firka/main.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:watch_connectivity/watch_connectivity.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../../../../helpers/api/token_grant.dart'; +import '../home/home_screen.dart'; +import '../login/login_screen.dart'; + +class WearLoginScreen extends StatefulWidget { + final AppInitialization data; + + const WearLoginScreen(this.data, {super.key}); + + @override + State createState() => _WearLoginScreenState(data); +} + +class _WearLoginScreenState extends State { + late WebViewController _webViewController; + final AppInitialization data; + + _WearLoginScreenState(this.data); + + final watch = WatchConnectivity(); + + @override + void initState() { + super.initState(); + + _webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..loadRequest(Uri.parse(KretaEndpoints.kretaLoginUrl)) + ..setNavigationDelegate(NavigationDelegate( + onNavigationRequest: (NavigationRequest request) async { + var uri = Uri.parse(request.url); + + if (uri.path == "/ellenorzo-student/prod/oauthredirect") { + if (kDebugMode) { + print("query params: ${uri.queryParameters}"); + } + + var code = uri.queryParameters["code"]!; + + try { + var resp = await getAccessToken(code); + + if (kDebugMode) { + print("getAccessToken(): $resp"); + } + + var tokenModel = TokenModel.fromResp(resp); + + debugPrint("[Phone -> Watch]: auth"); + watch.sendMessage({ + "id": "auth", + "data": { + "studentId": tokenModel.studentId, + "iss": tokenModel.iss, + "idToken": tokenModel.idToken, + "accessToken": tokenModel.accessToken, + "refreshToken": tokenModel.refreshToken, + "expiryDate": tokenModel.expiryDate!.millisecondsSinceEpoch + } + }); + + if (!mounted) return NavigationDecision.prevent; + + if (widget.data.tokenCount > 0) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => HomeScreen(widget.data)), + (route) => false, // Remove all previous routes + ); + } else { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => LoginScreen(widget.data)), + (route) => false, // Remove all previous routes + ); + } + } catch (ex) { + if (kDebugMode) { + print("oauthredirect failed: $ex"); + } + // TODO: display an error popup + } + + return NavigationDecision.prevent; + } + + return NavigationDecision.navigate; + })); + + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarBrightness: Brightness.light, + statusBarIconBrightness: Brightness.dark, + statusBarColor: Colors.transparent, + systemNavigationBarColor: Color(0xFFDAE4F7), + )); + } + + @override + Widget build(BuildContext context) { + final paddingWidthHorizontal = MediaQuery.of(context).size.width - + MediaQuery.of(context).size.width * 0.95; + + return MaterialApp( + home: Scaffold( + backgroundColor: const Color(0xFFFAFFEF), + body: SafeArea( + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 16), + Padding( + padding: EdgeInsets.only(left: paddingWidthHorizontal), + child: Row( + children: [ + Container( + width: 30, + height: 30, + clipBehavior: Clip.antiAlias, + decoration: ShapeDecoration( + image: const DecorationImage( + image: AssetImage( + 'assets/images/logos/colored_logo.png'), + fit: BoxFit.cover, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6)), + ), + ), + const SizedBox(width: 8), + const Text( + 'Firka', + style: TextStyle( + color: Color(0xFF394B0A), + fontSize: 17, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 700), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + Container( + padding: EdgeInsets.only(top: 16, left: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'Placeholder :3', + style: TextStyle( + color: Color(0xFF394B0A), + fontSize: 24, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 700), + ], + ), + ) + ], + ), + ), + ], + ), + Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + width: double.infinity, + height: 200, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x00DAE4F7), + Color(0xFFDAE4F7) + ], // customize colors + stops: [0.0, 0.5], // percentages (0% → 50% → 100%) + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ) + ], + ), + Positioned( + bottom: 10, + left: 0, + right: 0, + child: Column( + children: [ + Center( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: paddingWidthHorizontal), + child: InkWell( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context) + .viewInsets + .bottom), + child: FractionallySizedBox( + heightFactor: 0.90, + child: Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment.center, + mainAxisAlignment: + MainAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.symmetric( + vertical: 16), + child: Container( + decoration: const BoxDecoration( + color: Color(0xFFB9C8E5), + borderRadius: + BorderRadius.all( + Radius.circular(2)), + ), + width: 40, + height: 4, + ), + ), + ], + ), + Container( + height: MediaQuery.of(context) + .size + .height * + 0.8, + // Adjust height for content + margin: const EdgeInsets.symmetric( + horizontal: 16), + // Add ClipRRect for circular edges + child: ClipRRect( + borderRadius: + BorderRadius.circular(20), + child: WebViewWidget( + controller: _webViewController, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + height: 48, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: ShapeDecoration( + color: const Color(0xFFA7DB21), // Accent-Accent + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + shadows: const [ + BoxShadow( + color: Color(0x33647E22), + blurRadius: 2, + offset: Offset(0, 1), + spreadRadius: 0, + ) + ], + ), + child: const Center( + child: Text( + 'Bejelentkezés E-Kréta fiókkal', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF394B0A), + // Text-Primary + fontSize: 17, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 700), + ], + letterSpacing: -0.30, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 20), + const Text( + 'Adatvédelmi tájékoztató', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0x7F394C0A) /* Text-Teritary */, + fontSize: 14, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 500), + ], + height: 1.30, + ), + ), + ], + ), + ), + ], + )), + ), + ); + } +} diff --git a/firka/lib/ui/phone/widgets/bottom_nav_icon.dart b/firka/lib/ui/phone/widgets/bottom_nav_icon.dart new file mode 100644 index 00000000..adc220bb --- /dev/null +++ b/firka/lib/ui/phone/widgets/bottom_nav_icon.dart @@ -0,0 +1,47 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../model/style.dart'; +import '../../widget/firka_icon.dart'; + +class BottomNavIconWidget extends StatelessWidget { + final void Function() onTap; + final bool active; + final Uint8List icon; + final String text; + final Color iconColor; + final Color textColor; + + const BottomNavIconWidget(this.onTap, this.active, this.icon, this.text, + this.iconColor, this.textColor, + {super.key}); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + onTap(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FirkaIconWidget(FirkaIconType.Majesticons, icon, + color: iconColor, size: 24) + .build(context), + const SizedBox(height: 4), + Text( + text, + style: active ? appStyle.fonts.B_12SB : appStyle.fonts.B_12R, + ), + ], + ), + ), + ), + ); + } +} diff --git a/firka/lib/ui/phone/widgets/bottom_tt_icon.dart b/firka/lib/ui/phone/widgets/bottom_tt_icon.dart new file mode 100644 index 00000000..063aa1b1 --- /dev/null +++ b/firka/lib/ui/phone/widgets/bottom_tt_icon.dart @@ -0,0 +1,44 @@ +import 'package:firka/helpers/extensions.dart'; +import 'package:flutter/material.dart'; + +import '../../model/style.dart'; + +class BottomTimeTableNavIconWidget extends StatelessWidget { + final void Function() onTap; + final bool active; + final DateTime date; + + const BottomTimeTableNavIconWidget(this.onTap, this.active, this.date, + {super.key}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + onTap(); + }, + child: Card( + color: + active ? appStyle.colors.buttonSecondaryFill : Colors.transparent, + shadowColor: Colors.transparent, + child: SizedBox( + width: 40, + height: 54, + child: Column( + children: [ + SizedBox(height: 6), + Text(date.format(context, FormatMode.da), + style: appStyle.fonts.H_16px + .apply(color: appStyle.colors.textPrimary)), + Text( + date.format(context, FormatMode.dd), + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textSecondary), + ) + ], + )), + ), + ); + } +} diff --git a/firka/lib/ui/phone/widgets/home_main_starting_soon.dart b/firka/lib/ui/phone/widgets/home_main_starting_soon.dart new file mode 100644 index 00000000..585d3d15 --- /dev/null +++ b/firka/lib/ui/phone/widgets/home_main_starting_soon.dart @@ -0,0 +1,102 @@ +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/l10n/app_localizations.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:firka/ui/widget/counter_digit.dart'; +import 'package:flutter/material.dart'; + +import '../../../helpers/api/model/timetable.dart'; + +class StartingSoonWidget extends StatelessWidget { + final List lessons; + final DateTime now; + + const StartingSoonWidget(this.now, this.lessons, {super.key}); + + @override + Widget build(BuildContext context) { + var diff = lessons.first.start.difference(now); + var hour = diff.inHours % 60; + var min = diff.inMinutes % 60; + var sec = diff.inSeconds % 60; + + var hourTxt = hour == 1 + ? AppLocalizations.of(context)!.starting_hour + : AppLocalizations.of(context)!.starting_hour_plural; + var minTxt = hour == 1 + ? AppLocalizations.of(context)!.starting_min + : AppLocalizations.of(context)!.starting_min_plural; + var secTxt = hour == 1 + ? AppLocalizations.of(context)!.starting_sec + : AppLocalizations.of(context)!.starting_sec_plural; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FirkaCard( + left: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox(width: 6), + Text( + AppLocalizations.of(context)!.starting_soon, + style: appStyle.fonts.H_16px + .apply(color: appStyle.colors.textPrimary), + ), + ], + ), + Row( + children: [ + CounterDigitWidget( + hour.toString(), + appStyle.fonts.H_16px + .apply(color: appStyle.colors.textPrimary)), + SizedBox(width: 2), + Text( + hourTxt, + style: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textPrimary), + ), + SizedBox(width: 4), + CounterDigitWidget( + (min / 10).floor().toString(), + appStyle.fonts.H_16px + .apply(color: appStyle.colors.textPrimary)), + CounterDigitWidget( + ((min % 10)).toString(), + appStyle.fonts.H_16px + .apply(color: appStyle.colors.textPrimary)), + SizedBox(width: 2), + Text( + minTxt, + style: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textPrimary), + ), + SizedBox(width: 4), + CounterDigitWidget( + (sec / 10).floor().toString(), + appStyle.fonts.H_16px + .apply(color: appStyle.colors.textPrimary)), + CounterDigitWidget( + ((sec % 10)).toString(), + appStyle.fonts.H_16px + .apply(color: appStyle.colors.textPrimary)), + SizedBox(width: 2), + Text( + secTxt, + style: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textPrimary), + ), + ], + ) + ], + ) + ], + right: [], + ) + ], + ); + } +} diff --git a/firka/lib/ui/phone/widgets/home_main_welcome.dart b/firka/lib/ui/phone/widgets/home_main_welcome.dart new file mode 100644 index 00000000..b2c9b1e9 --- /dev/null +++ b/firka/lib/ui/phone/widgets/home_main_welcome.dart @@ -0,0 +1,110 @@ +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/l10n/app_localizations.dart'; +import 'package:firka/ui/widget/firka_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:majesticons_flutter/majesticons_flutter.dart'; + +import '../../../helpers/api/model/student.dart'; +import '../../../helpers/api/model/timetable.dart'; +import '../../model/style.dart'; + +class WelcomeWidget extends StatelessWidget { + final Student student; + final List lessons; + final DateTime now; + + const WelcomeWidget(this.now, this.student, this.lessons, {super.key}); + + getIconForCycle(Cycle dayCycle) { + switch (dayCycle) { + case Cycle.morning: + return FirkaIconWidget(FirkaIconType.MajesticonsLocal, "sunSolid", + color: appStyle.colors.accent); + case Cycle.day: + return FirkaIconWidget( + FirkaIconType.MajesticonsLocal, "parkSolidSchool", + color: appStyle.colors.accent); + case Cycle.afternoon: + return FirkaIconWidget(FirkaIconType.Majesticons, Majesticon.moonSolid, + color: appStyle.colors.accent); + case Cycle.night: + return FirkaIconWidget(FirkaIconType.Majesticons, Majesticon.moonSolid, + color: appStyle.colors.accent); + } + } + + String getRawTitle(BuildContext context, String name, Cycle dayCycle) { + switch (dayCycle) { + case Cycle.morning: + return AppLocalizations.of(context)!.good_morning(name); + case Cycle.day: + return AppLocalizations.of(context)!.good_day(name); + case Cycle.afternoon: + return AppLocalizations.of(context)!.good_afternoon(name); + case Cycle.night: + return AppLocalizations.of(context)!.good_night(name); + } + } + + String getTitle(BuildContext context, Cycle dayCycle) { + var name = ""; + + try { + name = student.name.split(" ")[1]; + } catch (ex) { + name = student.name; + } + + if (lessons.isEmpty) { + return getRawTitle(context, name, dayCycle); + } else { + if (now.isBefore(lessons.first.start)) { + return getRawTitle(context, name, dayCycle); + } + return getRawTitle(context, name, dayCycle); + } + } + + String getSubtitle(BuildContext context, Cycle dayCycle) { + if (lessons.isEmpty) { + return now.format(context, FormatMode.welcome); + } else { + if (now.isBefore(lessons.first.start)) { + return now.format(context, FormatMode.welcome); + } + var lessonsLeft = + lessons.where((lesson) => lesson.start.isAfter(now)).length; + if (lessonsLeft < 1) { + return AppLocalizations.of(context)!.tomorrow_subtitle; + } + if (lessonsLeft == 1) { + return AppLocalizations.of(context)!.suffering_almost_over_subtitle; + } + if (lessonsLeft <= 3) { + return AppLocalizations.of(context)!.n_left_subtitle(lessonsLeft); + } + + return now.format(context, FormatMode.welcome); + } + } + + @override + Widget build(BuildContext context) { + var dayCycle = now.getDayCycle(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + getIconForCycle(dayCycle), + const SizedBox(height: 16.0), + Text(getTitle(context, dayCycle), + style: appStyle.fonts.H_H2 + .copyWith(color: appStyle.colors.textPrimary)), + const SizedBox(height: 2.0), + Text(getSubtitle(context, dayCycle), + style: appStyle.fonts.B_14R + .copyWith(color: appStyle.colors.textSecondary)), + ], + ); + } +} diff --git a/firka/lib/ui/phone/widgets/lesson.dart b/firka/lib/ui/phone/widgets/lesson.dart new file mode 100644 index 00000000..b91c96c1 --- /dev/null +++ b/firka/lib/ui/phone/widgets/lesson.dart @@ -0,0 +1,252 @@ +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/l10n/app_localizations.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:flutter/material.dart'; + +import '../../../helpers/api/model/timetable.dart'; +import '../../widget/class_icon.dart'; + +class LessonWidget extends StatelessWidget { + final int? lessonNo; + final Lesson lesson; + final Lesson? nextLesson; + + const LessonWidget(this.lessonNo, this.lesson, this.nextLesson, {super.key}); + + @override + Widget build(BuildContext context) { + var isSubstituted = lesson.substituteTeacher != null; + var isDismissed = lesson.type.name == "UresOra"; + + var accent = appStyle.colors.accent; + var secondary = appStyle.colors.secondary; + var bgColor = appStyle.colors.a15p; + + if (isSubstituted) { + accent = appStyle.colors.warningAccent; + secondary = appStyle.colors.warningText; + bgColor = appStyle.colors.warning15p; + } + if (isDismissed) { + accent = appStyle.colors.errorAccent; + secondary = appStyle.colors.errorText; + bgColor = appStyle.colors.error15p; + } + + List elements = []; + + elements.add(GestureDetector( + onTap: () { + showLessonBottomSheet( + context, lesson, lessonNo, accent, secondary, bgColor); + }, + child: FirkaCard( + left: [ + Card( + // TODO: improve this to match design + shadowColor: Colors.transparent, + color: bgColor, + child: Padding( + padding: EdgeInsets.all(4), + child: Text(lessonNo.toString(), + style: appStyle.fonts.B_12R.apply(color: secondary)), + ), + ), + Card( + shadowColor: Colors.transparent, + color: bgColor, + child: Padding( + padding: EdgeInsetsGeometry.all(4), + child: ClassIconWidget( + color: accent, + size: 20, + uid: lesson.uid, + className: lesson.name, + category: lesson.subject?.name ?? '', + ), + ), + ), + SizedBox(width: 8), + Text(lesson.subject?.name ?? "N/A", + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary)), + ], + right: [ + Text( + isDismissed + ? AppLocalizations.of(context)!.class_dismissed + : lesson.start.toLocal().format(context, FormatMode.hmm), + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textPrimary)), + isDismissed + ? SizedBox() + : Card( + shadowColor: Colors.transparent, + color: appStyle.colors.a15p, + child: Padding( + padding: EdgeInsets.all(4), + child: Text(lesson.roomName ?? '?', + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.secondary)), + ), + ), + ], + ), + )); + + if (isSubstituted) { + elements.add(FirkaCard( + left: [ + Text(AppLocalizations.of(context)!.class_substitution, + style: appStyle.fonts.H_14px + .apply(color: appStyle.colors.textPrimary)) + ], + right: [ + Text(lesson.substituteTeacher!, + style: appStyle.fonts.B_16R + .apply(color: appStyle.colors.textSecondary)) + ], + )); + } + + if (nextLesson != null) { + elements.add(SizedBox(height: 4)); + var breakMins = nextLesson!.start.difference(lesson.end).inMinutes; + + elements.add(FirkaCard( + left: [ + Text(AppLocalizations.of(context)!.breakTxt, + style: appStyle.fonts.B_14SB + .apply(color: appStyle.colors.textSecondary)) + ], + right: [ + Text( + "$breakMins ${breakMins == 1 ? AppLocalizations.of(context)!.starting_min : AppLocalizations.of(context)!.starting_min_plural}", + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textTertiary)) + ], + )); + elements.add(SizedBox(height: 4)); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: elements, + ); + } +} + +void showLessonBottomSheet(BuildContext context, Lesson lesson, int? lessonNo, + Color accent, Color secondary, Color bgColor) { + showModalBottomSheet( + context: context, + elevation: 100, + isScrollControlled: true, + enableDrag: true, + backgroundColor: Colors.transparent, + barrierColor: appStyle.colors.a15p, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.3, + ), + builder: (BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + decoration: BoxDecoration( + color: appStyle.colors.background, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Card( + // TODO: improve this to match design + shadowColor: Colors.transparent, + color: bgColor, + child: Padding( + padding: EdgeInsets.all(4), + child: Text(lessonNo.toString(), + style: appStyle.fonts.B_12R + .apply(color: secondary)), + ), + ), + Card( + shadowColor: Colors.transparent, + color: bgColor, + child: Padding( + padding: EdgeInsetsGeometry.all(6), + child: ClassIconWidget( + color: accent, + size: 20, + uid: lesson.uid, + className: lesson.name, + category: lesson.subject?.name ?? '', + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + lesson.name, + style: appStyle.fonts.H_18px + .apply(color: appStyle.colors.textPrimary), + ), + Text( + lesson.teacher ?? 'N/A', + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textPrimary), + ), + Text( + '${lesson.start.format(context, FormatMode.hmm)} - ${lesson.end.format(context, FormatMode.hmm)}', + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textPrimary), + ), + ], + ), + ), + SizedBox(height: 8), + FirkaCard(left: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.lesson_subject, + style: appStyle.fonts.H_14px, + ), + SizedBox(height: 4), + Text( + lesson.theme ?? 'N/A', + style: appStyle.fonts.B_16R, + ) + ], + ) + ]) + ], + ), + ), + ), + ), + ], + ); + }, + ); +} diff --git a/firka/lib/ui/phone/widgets/lesson_big.dart b/firka/lib/ui/phone/widgets/lesson_big.dart new file mode 100644 index 00000000..c420a7d6 --- /dev/null +++ b/firka/lib/ui/phone/widgets/lesson_big.dart @@ -0,0 +1,225 @@ +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/l10n/app_localizations.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:firka/ui/widget/firka_icon.dart'; +import 'package:flutter/material.dart'; + +import '../../../helpers/api/model/timetable.dart'; +import '../../widget/class_icon.dart'; + +class LessonBigWidget extends StatelessWidget { + final DateTime now; + final int? lessonNo; + final Lesson? lesson; + final Lesson? prevLesson; + final Lesson? nextLesson; + + const LessonBigWidget( + this.now, this.lessonNo, this.lesson, this.prevLesson, this.nextLesson, + {super.key}); + + @override + Widget build(BuildContext context) { + var hasLesson = lesson != null; + var hasPrevLesson = prevLesson != null; + var hasNextLesson = nextLesson != null; + + if (!hasLesson && (!hasPrevLesson || !hasNextLesson)) { + throw Exception( + '!hasLesson($hasLesson) && (!hasPrevLesson($hasPrevLesson) || ' + '!hasNextLesson($hasNextLesson))'); + } + + if (hasLesson) { + var timeLeft = lesson!.end.difference(now); + var duration = lesson!.end.difference(lesson!.start).inMilliseconds; + var progress = now.difference(lesson!.start).inMilliseconds; + + var minsLeft = timeLeft.inMinutes; + var secsLeft = timeLeft.inSeconds; + + var timeLeftStr = + "$minsLeft ${minsLeft == 1 ? AppLocalizations.of(context)!.starting_min : AppLocalizations.of(context)!.starting_min_plural}"; + if (minsLeft < 1) { + timeLeftStr = + "$secsLeft ${secsLeft == 1 ? AppLocalizations.of(context)!.starting_sec : AppLocalizations.of(context)!.starting_sec_plural}"; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FirkaCard( + left: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Card( + // TODO: improve this to match design + shadowColor: Colors.transparent, + color: appStyle.colors.a15p, + child: Padding( + padding: EdgeInsets.all(4), + child: Text(lessonNo.toString(), + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.secondary)), + ), + ), + Card( + shadowColor: Colors.transparent, + color: appStyle.colors.a15p, + child: Padding( + padding: EdgeInsets.all(4), + child: ClassIconWidget( + color: wearStyle.colors.accent, + size: 24, + uid: lesson!.uid, + className: lesson!.name, + category: lesson!.subject?.name ?? '', + ), + ), + ), + Text(lesson!.subject?.name ?? 'N/A', + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary)), + ], + ), + Row( + children: [ + Text(timeLeftStr, + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.textSecondary)), + ], + ), + ], + ) + ], + right: [ + Column( + children: [ + Row( + children: [ + Text( + lesson!.start + .toLocal() + .format(context, FormatMode.hmm), + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textPrimary)), + Card( + shadowColor: Colors.transparent, + color: appStyle.colors.a15p, + child: Padding( + padding: EdgeInsets.all(4), + child: Text(lesson!.roomName ?? '?', + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.secondary)), + ), + ) + ], + ), + Row( + children: [ + SizedBox(width: 18), + Text( + lesson!.end.toLocal().format(context, FormatMode.hmm), + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.textSecondary)), + ], + ) + ], + ) + ], + extra: LinearProgressIndicator( + // TODO: Make this rounded + value: progress / duration, + backgroundColor: appStyle.colors.a15p, + color: appStyle.colors.accent, + ), + ) + ], + ); + } else { + var duration = + nextLesson!.start.difference(prevLesson!.end).inMilliseconds; + var progress = + duration - nextLesson!.start.difference(now).inMilliseconds; + var timeLeft = nextLesson!.start.difference(now); + + var timeLeftStr = + AppLocalizations.of(context)!.timeLeft(timeLeft.inMinutes + 1); + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FirkaCard( + left: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Card( + shadowColor: Colors.transparent, + color: appStyle.colors.a15p, + child: Padding( + padding: EdgeInsets.all(4), + child: FirkaIconWidget( + FirkaIconType.MajesticonsLocal, 'cupFilled', + color: wearStyle.colors.accent, size: 24), + ), + ), + Text(AppLocalizations.of(context)!.breakTxt, + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary)), + ], + ), + Row( + children: [ + Text(timeLeftStr, + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.textSecondary)), + ], + ), + ], + ) + ], + right: [ + Column( + children: [ + Row( + children: [ + Text( + prevLesson!.end + .toLocal() + .format(context, FormatMode.hmm), + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textPrimary)), + ], + ), + Row( + children: [ + Text( + nextLesson!.start + .toLocal() + .format(context, FormatMode.hmm), + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textPrimary)), + ], + ) + ], + ) + ], + extra: LinearProgressIndicator( + // TODO: Make this rounded + value: progress / duration, + backgroundColor: appStyle.colors.a15p, + color: appStyle.colors.accent, + ), + ) + ], + ); + } + } +} diff --git a/firka/lib/ui/phone/widgets/lesson_small.dart b/firka/lib/ui/phone/widgets/lesson_small.dart new file mode 100644 index 00000000..2e16ec2c --- /dev/null +++ b/firka/lib/ui/phone/widgets/lesson_small.dart @@ -0,0 +1,53 @@ +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/ui/model/style.dart'; +import 'package:flutter/material.dart'; + +import '../../../helpers/api/model/timetable.dart'; +import '../../widget/class_icon.dart'; + +class LessonSmallWidget extends StatelessWidget { + final Lesson lesson; + + const LessonSmallWidget(this.lesson, {super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FirkaCard( + left: [ + ClassIconWidget( + color: wearStyle.colors.accent, + size: 20, + uid: lesson.uid, + className: lesson.name, + category: lesson.subject?.name ?? '', + ), + SizedBox(width: 8), + Text(lesson.subject?.name ?? "N/A", + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary)), + ], + right: [ + Card( + shadowColor: Colors.transparent, + color: appStyle.colors.a15p, + child: Padding( + padding: EdgeInsets.all(4), + child: Text(lesson.roomName ?? '?', + style: appStyle.fonts.B_12R + .apply(color: appStyle.colors.secondary)), + ), + ), + Text( + "${lesson.start.toLocal().format(context, FormatMode.hmm)} - ${lesson.end.toLocal().format(context, FormatMode.hmm)}", + style: appStyle.fonts.B_14R + .apply(color: appStyle.colors.textPrimary)), + ], + ) + ], + ); + } +} diff --git a/firka/lib/ui/wear/screens/home/home_screen.dart b/firka/lib/ui/wear/screens/home/home_screen.dart new file mode 100644 index 00000000..728a5a2c --- /dev/null +++ b/firka/lib/ui/wear/screens/home/home_screen.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:firka/helpers/api/model/timetable.dart'; +import 'package:firka/helpers/extensions.dart'; +import 'package:firka/ui/widget/class_icon.dart'; +import 'package:firka/wear_main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_arc_text/flutter_arc_text.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:zear_plus/wear_plus.dart'; + +import '../../../../helpers/debug_helper.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../model/style.dart'; +import '../../widgets/circular_progress_indicator.dart'; + +class WearHomeScreen extends StatefulWidget { + final WearAppInitialization data; + + const WearHomeScreen(this.data, {super.key}); + + @override + State createState() => _WearHomeScreenState(data); +} + +class _WearHomeScreenState extends State { + final WearAppInitialization data; + + _WearHomeScreenState(this.data); + + int? currentLessonNo; + List today = List.empty(growable: true); + String apiError = ""; + DateTime now = timeNow(); + Timer? timer; + bool init = false; + WearMode mode = WearMode.active; + final platform = MethodChannel('firka.app/main'); + + bool disposed = false; + + @override + void initState() { + super.initState(); + now = timeNow(); + + timer = Timer.periodic(Duration(seconds: 1), (timer) async { + setState(() { + now = timeNow(); + }); + }); + initStateAsync(); + } + + Future initStateAsync() async { + var kreta = data.client; + + now = timeNow(); + var todayStart = now.getMidnight(); + var todayEnd = todayStart.add(Duration(hours: 23, minutes: 59)); + var classes = await kreta.getTimeTable(todayStart, todayEnd); + + if (disposed) return; + setState(() { + if (classes.response != null) today = classes.response!; + if (classes.statusCode != 200) { + apiError = "Unexpected status : ${classes.statusCode}"; + } + if (classes.err != null) apiError = classes.err!; + + init = true; + }); + } + + (List, double) buildBody(BuildContext context, WearMode mode) { + ScreenUtil.init(context); + + var body = List.empty(growable: true); + if (!init) { + return (body, 255.h); + } + + if (today.isEmpty && apiError != "") { + body.add(Text( + apiError, + style: + wearStyle.fonts.H_18px.apply(color: wearStyle.colors.textPrimary), + textAlign: TextAlign.center, + )); + + return (body, 255.h); + } + if (today.isEmpty) { + body.add(Text( + AppLocalizations.of(context)!.noClasses, + style: + wearStyle.fonts.H_18px.apply(color: wearStyle.colors.textPrimary), + textAlign: TextAlign.center, + )); + + platform.invokeMethod('activity_cancel'); + return (body, 255.h); + } + if (now.isAfter(today.last.end)) { + body.add(Text( + AppLocalizations.of(context)!.noMoreClasses, + style: + wearStyle.fonts.H_18px.apply(color: wearStyle.colors.textPrimary), + textAlign: TextAlign.center, + )); + + platform.invokeMethod('activity_cancel'); + return (body, 300.h); + } + if (now.isBefore(today.first.start)) { + var untilFirst = today.first.start.difference(now); + + body.add(Text( + AppLocalizations.of(context)!.firstIn(untilFirst.formatDuration()), + style: + wearStyle.fonts.H_18px.apply(color: wearStyle.colors.textPrimary), + textAlign: TextAlign.center, + )); + + platform.invokeMethod('activity_update'); + return (body, 255.h); + } + currentLessonNo = null; + if (now.isAfter(today.first.start) && now.isBefore(today.last.end)) { + Lesson? currentLesson = today.getCurrentLesson(now); + Lesson? lastLesson = today.getPrevLesson(now); + Lesson? nextLesson = today.getNextLesson(now); + + if (currentLesson != null) { + currentLessonNo = today.getLessonNo(currentLesson); + } + + Duration? currentBreak; + Duration? currentBreakProgress; + + if (lastLesson != null && nextLesson != null) { + currentBreak = nextLesson.start.difference(lastLesson.end); + currentBreakProgress = nextLesson.start.difference(now); + } + + if (currentLesson == null) { + if (currentBreak == null) { + throw Exception("currentBreak == null"); + } + if (currentBreakProgress == null) { + throw Exception("currentBreakProgress == null"); + } + + var minutes = currentBreakProgress.inMinutes + 1; + + body.add(CustomPaint( + painter: CircularProgressPainter( + progress: currentBreakProgress.inMilliseconds / + currentBreak.inMilliseconds, + // progress: 5 / 10, + screenSize: MediaQuery.of(context).size, + strokeWidth: 4, + color: wearStyle.colors.accent), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 55.h), + Center( + child: Text( + AppLocalizations.of(context)!.breakTxt, + style: TextStyle( + color: wearStyle.colors.textPrimary, + fontSize: 14, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 600), + ], + ), + ), + ), + Center( + child: Text( + AppLocalizations.of(context)!.timeLeft(minutes), + style: TextStyle( + color: wearStyle.colors.textPrimary, + fontSize: 12, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 400), + ], + ), + ), + ) + ], + ))); + + platform.invokeMethod('activity_update'); + return (body, 200.h); + } else { + var duration = currentLesson.start.difference(currentLesson.end); + var elapsed = currentLesson.start.difference(now); + var timeLeft = currentLesson.end.difference(now); + + var minutes = timeLeft.inMinutes + 1; + + Widget nextLessonWidget = SizedBox(); + + if (nextLesson != null) { + nextLessonWidget = Center( + child: Text( + "→ ${nextLesson.name}, ${nextLesson.roomName}", + style: TextStyle( + color: wearStyle.colors.textPrimary, + fontSize: 12, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 400), + ], + ), + ), + ); + } + + body.add(CustomPaint( + painter: CircularProgressPainter( + progress: elapsed.inMilliseconds / duration.inMilliseconds, + screenSize: MediaQuery.of(context).size, + strokeWidth: 4, + color: wearStyle.colors.accent), + child: Column(children: [ + SizedBox(height: nextLesson == null ? 20.h : 0), + Center( + child: ClassIconWidget( + color: wearStyle.colors.accent, + size: 16, + uid: currentLesson.uid, + className: currentLesson.name, + category: currentLesson.subject?.name ?? '', + ).build(context), + ), + const SizedBox(height: 4), + Center( + child: Text( + "${currentLesson.name}, ${currentLesson.roomName}", + style: TextStyle( + color: wearStyle.colors.textPrimary, + fontSize: 14, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 600), + ], + ), + ), + ), + Center( + child: Text( + AppLocalizations.of(context)!.timeLeft(minutes), + style: TextStyle( + color: wearStyle.colors.textPrimary, + fontSize: 12, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 400), + ], + ), + ), + ), + const SizedBox(height: 8), + nextLessonWidget, + ]))); + + platform.invokeMethod('activity_update'); + return (body, 200.h); + } + } + + platform.invokeMethod('activity_cancel'); + throw Exception("unexpected state"); + } + + @override + Widget build(BuildContext context) { + Widget titleBar = SizedBox(); + + if (currentLessonNo != null) { + titleBar = ArcText( + radius: 99, + startAngle: pi / 180, + startAngleAlignment: StartAngleAlignment.center, + text: AppLocalizations.of(context)!.wearTitle(currentLessonNo!), + textStyle: TextStyle( + fontSize: 12, + color: wearStyle.colors.secondary, + fontFamily: 'Montserrat', + fontVariations: [ + FontVariation('wght', 500), + ], + ), + placement: Placement.inside, + ); + } + + return Scaffold( + backgroundColor: mode == WearMode.active + ? wearStyle.colors.background + : wearStyle.colors.backgroundAmoled, + body: Stack( + children: [ + Center( + child: titleBar, + ), + Center( + child: Column( + children: [ + WatchShape( + builder: (context, shape, child) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + child!, + ], + ); + }, + child: AmbientMode( + builder: (context, mode, child) { + if (this.mode != mode) { + Timer(Duration(milliseconds: 100), () { + setState(() { + this.mode = mode; + }); + }); + } + + var (body, padding) = buildBody(context, mode); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: EdgeInsets.only(top: padding), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [...body], + )), + ], + ); + }, + ), + ) + ], + ), + ) + ], + ), + ); + } + + @override + void dispose() { + super.dispose(); + timer?.cancel(); + disposed = true; + } +} diff --git a/firka/lib/ui/wear/screens/login/login_screen.dart b/firka/lib/ui/wear/screens/login/login_screen.dart new file mode 100644 index 00000000..d5c4e80d --- /dev/null +++ b/firka/lib/ui/wear/screens/login/login_screen.dart @@ -0,0 +1,278 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; + +import 'package:firka/helpers/api/client/kreta_client.dart'; +import 'package:firka/helpers/extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:watch_connectivity/watch_connectivity.dart'; +import 'package:zear_plus/wear_plus.dart'; + +import '../../../../helpers/db/models/token_model.dart'; +import '../../../../wear_main.dart'; +import '../../../model/style.dart'; +import '../home/home_screen.dart'; + +class WearLoginScreen extends StatefulWidget { + final WearAppInitialization data; + const WearLoginScreen(this.data, {super.key}); + + @override + State createState() => _WearLoginScreen(data); +} + +class _WearLoginScreen extends State { + final WearAppInitialization initData; + _WearLoginScreen(this.initData); + + bool init = false; + bool isPaired = false; + bool isReachable = false; + bool isMessageSending = false; + bool isMessageSent = false; + final watch = WatchConnectivity(); + late Timer connectionTimer; + + @override + void initState() { + super.initState(); + + watch.messageStream.listen((e) { + var msg = e.entries.toMap(); + var id = msg["id"]; + + debugPrint("[Phone -> Watch]: $id"); + + switch (id) { + case "pong": + { + setState(() { + isMessageSent = true; + }); + } + case "auth": + { + () async { + var data = msg["data"]; + var tokenModel = TokenModel.fromValues( + data["studentId"], + data["iss"], + data["idToken"], + data["accessToken"], + data["refreshToken"], + data["expiryDate"]); + + initData.client = KretaClient(tokenModel, initData.isar); + + await initData.isar.writeTxn(() async { + await initData.isar.tokenModels.put(tokenModel); + }); + + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute( + builder: (context) => WearHomeScreen(initData)), + (route) => false, // Remove all previous routes + ); + }(); + } + } + }); + + connectionTimer = Timer.periodic(Duration(seconds: 1), (timer) async { + var p = await watch.isPaired; + var r = await watch.isReachable; + + if (!isMessageSending) { + isMessageSending = true; + + debugPrint("[Watch -> Phone]: ping"); + watch.sendMessage({'id': 'ping'}); + } + + setState(() { + init = true; + isPaired = p; + isReachable = r; + }); + }); + } + + (List, double) buildBody(BuildContext context) { + if (!init) { + return ( + [ + Text( + "Loading...", + textAlign: TextAlign.center, + style: wearStyle.fonts.H_18px + .apply(color: wearStyle.colors.textPrimary), + ), + ], + 65 + ); + } + + if (!isPaired) { + return ( + [ + Text( + "Watch not paired with your phone", + textAlign: TextAlign.center, + style: wearStyle.fonts.H_18px + .apply(color: wearStyle.colors.textPrimary), + ), + ], + 65 + ); + } + if (!isReachable) { + return ( + [ + Text( + "Watch not connected\n to your phone", + textAlign: TextAlign.center, + style: wearStyle.fonts.H_18px + .apply(color: wearStyle.colors.textPrimary), + ), + ], + 65 + ); + } + + if (!isMessageSent && isMessageSending) { + return ( + [ + Text( + "Sending request...", + textAlign: TextAlign.center, + style: wearStyle.fonts.H_18px + .apply(color: wearStyle.colors.textPrimary), + ), + ElevatedButton( + onPressed: () async { + debugPrint("[Watch -> Phone]: ping"); + watch.sendMessage({'id': 'ping'}); + }, + // TODO: This is a placeholder, style this properly + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return wearStyle.colors.accent; + } + return wearStyle.colors.accent; + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return wearStyle.colors.accent; + } + return wearStyle.colors.accent; + }), + ), + child: Text('Try again', + textAlign: TextAlign.center, + style: TextStyle(color: wearStyle.colors.textPrimary)), + ), + ], + 45 + ); + } + + if (isMessageSent) { + return ( + [ + Text( + "Check your phone!", + textAlign: TextAlign.center, + style: wearStyle.fonts.H_18px + .apply(color: wearStyle.colors.textPrimary), + ), + ElevatedButton( + onPressed: () async { + debugPrint("[Watch -> Phone]: ping"); + watch.sendMessage({'id': 'ping'}); + }, + // TODO: This is a placeholder, style this properly + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return wearStyle.colors.accent; + } + return wearStyle.colors.accent; + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) { + return wearStyle.colors.accent; + } + return wearStyle.colors.accent; + }), + ), + child: Text('Try again', + textAlign: TextAlign.center, + style: TextStyle(color: wearStyle.colors.textPrimary)), + ), + ], + 45 + ); + } + + return ( + [ + Text("Unexpected state", + style: TextStyle(color: wearStyle.colors.textPrimary, fontSize: 18), + textAlign: TextAlign.center), + ], + 65 + ); + } + + @override + Widget build(BuildContext context) { + var (body, offset) = buildBody(context); + + return Scaffold( + backgroundColor: wearStyle.colors.background, + body: Center( + child: Column( + children: [ + WatchShape( + builder: (context, shape, child) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + "Login", + textAlign: TextAlign.center, + style: TextStyle( + color: wearStyle.colors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w500, + ), + ), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: EdgeInsets.only(top: offset), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: body, + )), + ], + ), + child!, + ], + ); + }, + child: SizedBox()) + ], + ), + ), + ); + } + + @override + void dispose() { + super.dispose(); + connectionTimer.cancel(); + } +} diff --git a/firka/lib/ui/wear/widgets/circular_progress_indicator.dart b/firka/lib/ui/wear/widgets/circular_progress_indicator.dart new file mode 100644 index 00000000..075b49e3 --- /dev/null +++ b/firka/lib/ui/wear/widgets/circular_progress_indicator.dart @@ -0,0 +1,115 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class CircularProgressIndicatorWidget extends StatefulWidget { + final double progress; + final double strokeWidth; + final Color color; + final Size screenSize; + + const CircularProgressIndicatorWidget({ + super.key, + required this.progress, + required this.screenSize, + this.strokeWidth = 8.0, + required this.color, + }); + + @override + _CircularProgressIndicatorWidgetState createState() => + _CircularProgressIndicatorWidgetState(); +} + +class _CircularProgressIndicatorWidgetState + extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _animation = + Tween(begin: 0.0, end: widget.progress).animate(_controller); + _controller.forward(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return CustomPaint( + painter: CircularProgressPainter( + progress: _animation.value, + strokeWidth: widget.strokeWidth, + color: widget.color, + screenSize: widget.screenSize, + ), + child: SizedBox.expand(), // Fill the entire screen + ); + }, + ); + } +} + +class CircularProgressPainter extends CustomPainter { + final double progress; + final double strokeWidth; + final Color color; + final Size screenSize; + + CircularProgressPainter({ + required this.progress, + required this.strokeWidth, + required this.color, + required this.screenSize, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(screenSize.width / 2, screenSize.height / 4.7); + final radius = + min(screenSize.width, screenSize.height) / 2 - strokeWidth / 2; + final startAngle = -pi / 2; + var sweepAngle = 2 * pi * progress; + + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + + if (sweepAngle >= 6) { + sweepAngle -= 0.6; + } else { + sweepAngle -= 0.35; + + if (sweepAngle > 5.4) sweepAngle = 5.4; + } + if (sweepAngle <= 0) sweepAngle = 0; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + startAngle + 0.3, + sweepAngle, + false, + paint, + ); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return true; + } +} diff --git a/firka/lib/ui/widget/class_icon.dart b/firka/lib/ui/widget/class_icon.dart new file mode 100644 index 00000000..396fe955 --- /dev/null +++ b/firka/lib/ui/widget/class_icon.dart @@ -0,0 +1,31 @@ +import 'package:firka/helpers/icon_helper.dart'; +import 'package:flutter/material.dart'; + +import 'firka_icon.dart'; + +class ClassIconWidget extends StatelessWidget { + final String _uid; + final String _className; + final String _category; + final Color color; + final double? size; + + const ClassIconWidget( + {super.key, + required String uid, + required String className, + required String category, + this.color = Colors.white, + this.size}) + : _className = className, + _uid = uid, + _category = category; + + @override + Widget build(BuildContext context) { + var iconCategory = getIconType(_uid, _className, _category); + + return FirkaIconWidget(FirkaIconType.Majesticons, getIconData(iconCategory), + color: color, size: size); + } +} diff --git a/firka/lib/ui/widget/counter_digit.dart b/firka/lib/ui/widget/counter_digit.dart new file mode 100644 index 00000000..9c13f35d --- /dev/null +++ b/firka/lib/ui/widget/counter_digit.dart @@ -0,0 +1,24 @@ +import 'package:firka/ui/model/style.dart'; +import 'package:flutter/material.dart'; + +class CounterDigitWidget extends StatelessWidget { + final String c; + final TextStyle? style; + + const CounterDigitWidget(this.c, this.style, {super.key}); + + @override + Widget build(BuildContext context) { + return Card( + shadowColor: Colors.transparent, + color: appStyle.colors.buttonSecondaryFill, + child: Padding( + padding: EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 4), + child: Text( + c, + style: style, + ), + ), + ); + } +} diff --git a/firka/lib/ui/widget/delayed_spinner.dart b/firka/lib/ui/widget/delayed_spinner.dart new file mode 100644 index 00000000..53e4b655 --- /dev/null +++ b/firka/lib/ui/widget/delayed_spinner.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class DelayedSpinnerWidget extends StatefulWidget { + const DelayedSpinnerWidget({super.key}); + + @override + State createState() => _DelayedSpinner(); +} + +class _DelayedSpinner extends State { + Timer? timer; + bool showSpinner = false; + + @override + void initState() { + super.initState(); + + timer = Timer(Duration(milliseconds: 50), () { + setState(() { + showSpinner = true; + }); + }); + } + + @override + Widget build(BuildContext context) { + if (showSpinner) { + return CircularProgressIndicator(); + } else { + return SizedBox(); + } + } + + @override + void dispose() { + super.dispose(); + + timer?.cancel(); + } +} diff --git a/firka/lib/ui/widget/firka_icon.dart b/firka/lib/ui/widget/firka_icon.dart new file mode 100644 index 00000000..fa66125e --- /dev/null +++ b/firka/lib/ui/widget/firka_icon.dart @@ -0,0 +1,34 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:majesticons_flutter/majesticons_flutter.dart'; + +enum FirkaIconType { + Majesticons, + MajesticonsLocal, +} + +class FirkaIconWidget extends StatelessWidget { + final FirkaIconType iconType; + final Object iconData; + final Color color; + final double? size; + + const FirkaIconWidget(this.iconType, this.iconData, + {super.key, this.color = Colors.white, this.size}); + + @override + Widget build(BuildContext context) { + switch (iconType) { + case FirkaIconType.Majesticons: + return Majesticon(iconData as Uint8List, color: color, size: size); + case FirkaIconType.MajesticonsLocal: + return SvgPicture.asset( + 'assets/majesticons/${iconData as String}.svg', + color: color, + height: size, + ); + } + } +} diff --git a/firka/lib/ui/widget/grade_small_card.dart b/firka/lib/ui/widget/grade_small_card.dart new file mode 100644 index 00000000..b070d839 --- /dev/null +++ b/firka/lib/ui/widget/grade_small_card.dart @@ -0,0 +1,52 @@ +import 'package:firka/helpers/api/model/grade.dart'; +import 'package:firka/helpers/api/model/subject.dart'; +import 'package:firka/helpers/ui/firka_card.dart'; +import 'package:firka/helpers/ui/grade_helpers.dart'; +import 'package:firka/ui/widget/class_icon.dart'; +import 'package:flutter/material.dart'; + +import '../model/style.dart'; + +class GradeSmallCard extends FirkaCard { + final List grades; + final Subject subject; + + GradeSmallCard(this.grades, this.subject, {super.key}) + : super(left: [ + ClassIconWidget( + uid: subject.uid, + className: subject.name, + category: subject.category.name!, + color: appStyle.colors.accent, + ), + SizedBox( + width: 4, + ), + SizedBox( + width: 200, + child: Text( + subject.name, + style: appStyle.fonts.B_16SB + .apply(color: appStyle.colors.textPrimary), + ), + ), + ], right: [ + grades.getAverageBySubject(subject).isNaN + ? SizedBox() + : Card( + shadowColor: Colors.transparent, + color: getGradeColor(grades.getAverageBySubject(subject)) + .withAlpha(38), + child: Padding( + padding: + EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 4), + child: Text( + grades.getAverageBySubject(subject).toStringAsFixed(2), + style: appStyle.fonts.B_16SB.apply( + color: getGradeColor( + grades.getAverageBySubject(subject))), + ), + ), + ), + ]); +} diff --git a/firka/lib/wear_main.dart b/firka/lib/wear_main.dart new file mode 100644 index 00000000..b7d5b804 --- /dev/null +++ b/firka/lib/wear_main.dart @@ -0,0 +1,180 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:firka/helpers/db/models/generic_cache_model.dart'; +import 'package:firka/helpers/db/models/homework_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/ui/wear/screens/login/login_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:isar/isar.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:zear_plus/wear_plus.dart'; + +import 'helpers/api/client/kreta_client.dart'; +import 'l10n/app_localizations.dart'; +import 'ui/wear/screens/home/home_screen.dart'; + +Isar? isarInit; +final GlobalKey navigatorKey = GlobalKey(); + +class WearAppInitialization { + final Isar isar; + late KretaClient client; + final int tokenCount; + + WearAppInitialization({required this.isar, required this.tokenCount}); +} + +Future initDB() async { + if (isarInit != null) return isarInit!; + final dir = await getApplicationDocumentsDirectory(); + + isarInit = await Isar.open( + [ + TokenModelSchema, + GenericCacheModelSchema, + TimetableCacheModelSchema, + HomeworkCacheModelSchema + ], + inspector: true, + directory: dir.path, + ); + + return isarInit!; +} + +Future initializeApp() async { + final isar = await initDB(); + + var init = WearAppInitialization( + isar: isar, tokenCount: await isar.tokenModels.count()); + + resetOldTimeTableCache(isar); + resetOldHomeworkCache(isar); + + // TODO: Account selection + if (init.tokenCount > 0) { + init.client = + KretaClient((await isar.tokenModels.where().findFirst())!, isar); + } + + return init; +} + +void wearMain(MethodChannel platform) async { + WidgetsFlutterBinding.ensureInitialized(); + + if (await Permission.notification.isDenied) { + var status = await Permission.notification.request(); + + if (status.isDenied) { + exit(-1); + } + } + + await ScreenUtil.ensureScreenSize(); + + // Run App Initialization + runApp(WearInitializationScreen()); +} + +class WearInitializationScreen extends StatelessWidget { + WearInitializationScreen({super.key}); + + // Place to store the initialization future + final Future _initialization = initializeApp(); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _initialization, + builder: (context, snapshot) { + // Check if initialization is complete + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + // Handle initialization error + + return MaterialApp( + key: ValueKey('firkaErrorPage'), + home: Scaffold( + body: Center( + child: WatchShape( + builder: (context, shape, child) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Error initializing app: ${snapshot.error}', + style: TextStyle(color: Colors.red), + ), + child!, + ], + ); + }, + child: SizedBox(), + ), + ), + ), + ); + } + + // Initialization successful, determine which screen to show + Widget screen; + + assert(snapshot.data != null); + var data = snapshot.data!; + + if (snapshot.data!.tokenCount == 0) { + screen = WearLoginScreen(data, key: ValueKey('wearLoginScreen')); + } else { + screen = WearHomeScreen(data, key: ValueKey('wearHomeScreen')); + } + + return MaterialApp( + key: ValueKey('firkaWearApp'), + title: 'Firka', + navigatorKey: navigatorKey, + // Use the global navigator key + theme: ThemeData( + primarySwatch: Colors.lightGreen, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + localizationsDelegates: [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: screen, + routes: { + '/login': (context) => + WearLoginScreen(data, key: ValueKey('wearLoginScreen')), + '/home': (context) => + WearHomeScreen(data, key: ValueKey('wearHomeScreen')) + }, + ); + } + + return MaterialApp( + home: Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + color: const Color(0xFF7CA021), + ) + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/firka/pubspec.yaml b/firka/pubspec.yaml new file mode 100644 index 00000000..2c766209 --- /dev/null +++ b/firka/pubspec.yaml @@ -0,0 +1,133 @@ +# TODO: make app icon with adaptive functions: https://pub.dev/packages/flutter_launcher_icons + +name: firka +description: "Firka, Alternatív e-Kréta kliens." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1002 + +environment: + sdk: ">=3.6.0 <=3.8.1" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + flutter_launcher_icons: ^0.14.3 + dio: ^5.8.0+1 + isar: + path: vendor/isar + isar_flutter_libs: + path: vendor/isar_flutter_libs + build_runner: any + path_provider: ^2.1.0 + carousel_slider: ^5.0.0 + webview_flutter: ^4.7.0 + dart_jsonwebtoken: ^3.2.0 + zear_plus: + path: vendor/wear_plus + majesticons_flutter: ^0.0.1 + watch_connectivity: ^0.2.1+1 + permission_handler: ^11.4.0 + flutter_localizations: + sdk: flutter + intl: any + image_picker: ^1.1.2 + image: ^4.5.4 + path: ^1.9.1 + flutter_screenutil: ^5.9.3 + flutter_arc_text: ^0.6.0 + flutter_svg: ^1.1.6 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + isar_generator: + path: vendor/isar_generator + android_notification_icons: ^0.0.1 + integration_test: + sdk: flutter + +android_notification_icons: + image_path: 'assets/images/logos/monochrome_logo.png' + icon_name: 'ic_notification' + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + generate: true + uses-material-design: true + assets: + - assets/images/logos/colored_logo.png + - assets/images/logos/dave.svg + - assets/images/carousel/ + - assets/majesticons/ + + fonts: + - family: Montserrat + fonts: + - asset: assets/fonts/Montserrat-VariableFont_wght.ttf + style: normal + - family: Figtree + fonts: + - asset: assets/fonts/Figtree-VariableFont_wght.ttf + style: normal + - family: RobotoMono + fonts: + - asset: assets/fonts/RobotoMono-VariableFont_wght.ttf + style: normal + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/firka/vendor/isar b/firka/vendor/isar new file mode 160000 index 00000000..4bf4cf07 --- /dev/null +++ b/firka/vendor/isar @@ -0,0 +1 @@ +Subproject commit 4bf4cf07e317550307fcb7251bf3e67708514bb1 diff --git a/firka/vendor/isar_flutter_libs b/firka/vendor/isar_flutter_libs new file mode 160000 index 00000000..d171b4bf --- /dev/null +++ b/firka/vendor/isar_flutter_libs @@ -0,0 +1 @@ +Subproject commit d171b4bf8764f947dfb4efcef779c7d3805e50ac diff --git a/firka/vendor/isar_generator b/firka/vendor/isar_generator new file mode 160000 index 00000000..97af6caa --- /dev/null +++ b/firka/vendor/isar_generator @@ -0,0 +1 @@ +Subproject commit 97af6caaa566b1dad91fab5f34a3199fd5391dda diff --git a/firka/vendor/wear_plus b/firka/vendor/wear_plus new file mode 160000 index 00000000..a6fb67b2 --- /dev/null +++ b/firka/vendor/wear_plus @@ -0,0 +1 @@ +Subproject commit a6fb67b23edea4b2657bbd0cbb7150de85b88db6 diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 00000000..ada4c927 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,40 @@ +# hogyan hozz létre upload-keystore-t flutterhez? + +ha ezt olvasod, akkor valószínűleg szeretnéd a refilc appot build-elni. ha bármi kérdésed lenne, nyugodtan keress minket discordon, vagy akár emailben is! + +## 1. keystore létrehozása + +először, nyiss egy terminált ebben a mappában és futtasd ezt a parancsot: + +```sh +keytool -genkeypair -v \ + -keystore upload-keystore.jks \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -alias upload +``` + +ezután meg kell adnod néhány adatot: +- egy jelszót a keystore-hoz (ezt jegyezd meg!) +- nevedet, szervezeted nevét (nyugodtan hagyhatod alapértelmezetten) +- egy második jelszót az „upload” aliashoz (ajánlott az előzőt használni) + +ha minden jól megy, egy `upload-keystore.jks` fájl létrejön. + +## 3. keystore.properties létrehozása + +hozz létre egy új fájlt `keystore.properties` néven, és írd bele a következőt: + +```properties +storeFile=upload-keystore.jks +storePassword=password +keyPassword=password +keyAlias=upload +``` + +cseréld ki a `password` részeket, természetesen a választott jelszavadra. + +## 4. secrets mappa kizárása a gitből + +már beleraktuk a .gitignore mappába a kizárását ennek, ezen nem kell aggódnod. + +készen is vagy, sok sikert! diff --git a/secrets/README_en.md b/secrets/README_en.md new file mode 100644 index 00000000..ada4c927 --- /dev/null +++ b/secrets/README_en.md @@ -0,0 +1,40 @@ +# hogyan hozz létre upload-keystore-t flutterhez? + +ha ezt olvasod, akkor valószínűleg szeretnéd a refilc appot build-elni. ha bármi kérdésed lenne, nyugodtan keress minket discordon, vagy akár emailben is! + +## 1. keystore létrehozása + +először, nyiss egy terminált ebben a mappában és futtasd ezt a parancsot: + +```sh +keytool -genkeypair -v \ + -keystore upload-keystore.jks \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -alias upload +``` + +ezután meg kell adnod néhány adatot: +- egy jelszót a keystore-hoz (ezt jegyezd meg!) +- nevedet, szervezeted nevét (nyugodtan hagyhatod alapértelmezetten) +- egy második jelszót az „upload” aliashoz (ajánlott az előzőt használni) + +ha minden jól megy, egy `upload-keystore.jks` fájl létrejön. + +## 3. keystore.properties létrehozása + +hozz létre egy új fájlt `keystore.properties` néven, és írd bele a következőt: + +```properties +storeFile=upload-keystore.jks +storePassword=password +keyPassword=password +keyAlias=upload +``` + +cseréld ki a `password` részeket, természetesen a választott jelszavadra. + +## 4. secrets mappa kizárása a gitből + +már beleraktuk a .gitignore mappába a kizárását ennek, ezen nem kell aggódnod. + +készen is vagy, sok sikert! diff --git a/tools/linux/build_apk.sh b/tools/linux/build_apk.sh new file mode 100755 index 00000000..006afc8f --- /dev/null +++ b/tools/linux/build_apk.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +cd firka + +flutter gen-l10n --template-arb-file app_hu.arb + +if [ "$1" = "main" ]; then + if [ -f "$HOME/.flutter_path" ]; then + sdk_path="$(cat $HOME/.flutter_path)" + echo "Using flutter sdk from: $sdk_path" + + mkdir -p build/app/tmp + flutter build apk --release --tree-shake-icons \ + --local-engine-src-path "$sdk_path/engine/src" \ + --local-engine=android_release --local-engine-host=host_release \ + --split-per-abi \ + --target-platform android-arm + mv build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk build/app/tmp/ + flutter build apk --release --tree-shake-icons \ + --local-engine-src-path "$sdk_path/engine/src" \ + --local-engine=android_release_arm64 --local-engine-host=host_release \ + --split-per-abi \ + --target-platform android-arm64 + mv build/app/outputs/flutter-apk/app-arm64-v8a-release.apk build/app/tmp/ + flutter build apk --release --tree-shake-icons \ + --local-engine-src-path "$sdk_path/engine/src" \ + --local-engine=android_release_x64 --local-engine-host=host_release \ + --split-per-abi \ + --target-platform android-x64 + mv build/app/tmp/*.apk build/app/outputs/flutter-apk + else + echo "$HOME/.flutter_path not found!" + exit 1 + fi +else + flutter build apk --debug --target-platform android-arm,android-arm64,android-x64 +fi diff --git a/tools/windows/build_apk.bat b/tools/windows/build_apk.bat new file mode 100644 index 00000000..f12e2864 --- /dev/null +++ b/tools/windows/build_apk.bat @@ -0,0 +1,9 @@ +@echo off + +cd firka +flutter clean +flutter pub get + +flutter gen-l10n --template-arb-file app_hu.arb + +flutter build apk --release --tree-shake-icons --split-per-abi --target-platform android-arm,android-arm64,android-x64